Skip to content

AsyncQueue fix and improvements - #52

Merged
rintaro merged 3 commits into
swiftlang:mainfrom
rintaro:asyncqueue-fixes-and-mutex
May 22, 2026
Merged

AsyncQueue fix and improvements#52
rintaro merged 3 commits into
swiftlang:mainfrom
rintaro:asyncqueue-fixes-and-mutex

Conversation

@rintaro

@rintaro rintaro commented May 21, 2026

Copy link
Copy Markdown
Member
  • Fix cleanup leak on throw/cancel. PendingTask entries were removed from tasksByMetadata only after operation() returned successfully. If the operation threw or the task was cancelled, the entry stayed forever, growing the dictionary unboundedly per failure. Fixed by moving the cleanup into a defer.
  • Fix wrong metadata in the dependency-collapse optimization. The "wait only on the bucket's last task" shortcut checked metadata.isDependency(of: metadata) (the new task's self-dependency) instead of pendingMetadata.isDependency(of: pendingMetadata) (the bucket's). With cross-metadata dependencies where the new kind serializes with itself but the bucket's kind doesn't, the new task could start before earlier concurrent tasks in that bucket finished.
  • Migrate the lock from NSLock to Synchronization.Mutex. The PendingTasks helper class is gone; the dictionary now lives directly inside a Mutex<...> property of AsyncQueue. Drops the NSLock + nonisolated(unsafe) var pair and lets the compiler verify Sendable properly.

rintaro added 2 commits May 21, 2026 11:55
- Wrap PendingTask removal in `defer` so a thrown or cancelled
  operation no longer leaves stale entries in `tasksByMetadata`.
- The "depend only on the bucket's last task" optimization checked
  `metadata.isDependency(of: metadata)` (the new task's self-dep)
  instead of `pendingMetadata.isDependency(of: pendingMetadata)`
  (the bucket's). With cross-metadata dependencies, the new task
  could start before earlier concurrent tasks in that bucket.
Inline the PendingTasks helper so the dictionary lives directly
inside a Mutex<...> property of AsyncQueue. Drops the NSLock +
nonisolated(unsafe) pair, removes the helper class, and lets the
compiler verify Sendability without the misleading "Unchecked
sendable" comment. The "AsyncQueue" lock name is lost since Mutex
doesn't carry one. The inner Task closure now captures self
implicitly (AsyncQueue is Sendable) instead of the helper instance;
memory ownership is equivalent.
Comment thread Sources/ToolsProtocolsSwiftExtensions/AsyncQueue.swift
@rintaro

rintaro commented May 22, 2026

Copy link
Copy Markdown
Member Author

@ahoppen ahoppen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice findings 🤩

Just a few comments on the tests.

/// - `.serial` is self-serializing
/// - `.concurrent` is a dependency of `.serial` (but not vice versa)
///
/// This is the configuration that exposes the bucket-self-dependency bug:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think we should reference the … bug. It’s fixed by this PR.

}
}

/// Regression test: a `.serial` task depending on a non-self-serializing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression test also doesn’t make sense as a comment going forward

Comment thread Tests/ToolsProtocolsSwiftExtensionsTests/AsyncQueueTests.swift
serialRan.value = true
}

// Release the last concurrent task. With the previous (buggy)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, please don’t reference a previous implementation.

// Release the last concurrent task. With the previous (buggy)
// optimization, the serial task waited only on the bucket's last entry,
// so this would let it proceed.
cont3.yield()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need to yield values here? Shouldn’t finishing the continuation be sufficient?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely right. finish() is enough.

// For self-serializing metadata, only the latest task matters as a dependency —
// it transitively covers all previous ones. Replace rather than append.
if metadata.isDependency(of: metadata) {
tasksByMetadata[metadata] = [PendingTask(task: task, id: id)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics of tasksByMetadata because it now no longer has a reference to all the pending tasks for that metadata. I don’t think that’s an issue but the doc comment would need to be updated and also tasksByMetadata probably isn’t the best name for the variable anymore.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry I mixed a WIP changes. Reverting it..

Verifies the bucket-self-dependency optimization waits on every
entry in the bucket, not just the last.
@rintaro
rintaro force-pushed the asyncqueue-fixes-and-mutex branch from e1fca19 to 151e6fc Compare May 22, 2026 13:34
@rintaro
rintaro merged commit ffc1e3c into swiftlang:main May 22, 2026
47 of 53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants