Repository navigation
fix: wait for control socket dispatch sources before close #13292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
ed0fcec
fix: wait for control socket source cancellation before close
austinywang 2cba382
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang e13d7f3
fix: await control socket source teardown asynchronously
austinywang 87e7720
test: make writer cancellation coverage deterministic
austinywang 0a31fad
fix: complete async source teardown review
austinywang ee5eef5
test: observe writable registration through the runtime operation
austinywang 753a2c8
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang cb45343
test: drive real writer backpressure through an observed executor
austinywang 6451d87
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang ae6ef28
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang 958211f
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang 0c5af74
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang d4bad80
style: add explicit deinitializers to socket lifecycle types
austinywang 3560068
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang 49d5ea2
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang 639e42f
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
109 changes: 109 additions & 0 deletions
109
...s/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Server/ControlClientAsyncWriter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| internal import Dispatch | ||
| public import Foundation | ||
| internal import Darwin | ||
|
|
||
| /// Asynchronously writes one connection's responses without parking a thread | ||
| /// when a client applies backpressure. One writer task owns the instance. | ||
| // @unchecked Sendable is safe because one connection task performs all writes; | ||
| // the one-shot writable source communicates only through its continuation. | ||
| public final class ControlClientAsyncWriter: @unchecked Sendable { | ||
| private final class SourceBox: @unchecked Sendable { | ||
| var source: (any DispatchSourceWrite)? | ||
| } | ||
|
|
||
| private let socket: Int32 | ||
| /// One-shot writable sources must finish cancellation before the owner | ||
| /// closes the shared socket descriptor. | ||
| private let sourceCancellationBarrier = DispatchSourceCancellationBarrier() | ||
|
|
||
| /// Creates a writer over a non-blocking descriptor. | ||
| /// | ||
| /// - Parameter socket: A borrowed descriptor retained by the connection owner. | ||
| public init(socket: Int32) { | ||
| self.socket = socket | ||
| _ = Self.makeNonBlocking(socket) | ||
| } | ||
|
|
||
| /// Writes all bytes, suspending on `EAGAIN`; returns false after EOF, | ||
| /// cancellation, or a non-retryable write error. | ||
| public func writeAll(_ data: Data) async -> Bool { | ||
| var offset = 0 | ||
| while offset < data.count, !Task.isCancelled { | ||
| let written = data.withUnsafeBytes { rawBuffer -> Int in | ||
| guard let baseAddress = rawBuffer.baseAddress else { return 0 } | ||
| return Darwin.write( | ||
| socket, | ||
| baseAddress.advanced(by: offset), | ||
| rawBuffer.count - offset | ||
| ) | ||
| } | ||
| if written > 0 { | ||
| offset += written | ||
| continue | ||
| } | ||
| if written < 0, errno == EINTR { continue } | ||
| if written < 0, errno == EAGAIN || errno == EWOULDBLOCK { | ||
| let source = DispatchSource.makeWriteSource( | ||
| fileDescriptor: socket, | ||
| queue: DispatchQueue.global(qos: .utility) | ||
| ) | ||
| guard await waitForWritable(source: source) else { return false } | ||
| continue | ||
| } | ||
| return false | ||
| } | ||
| return offset == data.count | ||
| } | ||
|
|
||
| /// Waits for any one-shot writable source to finish cancellation. | ||
| /// | ||
| /// Call after the owning write task has returned. To interrupt a write, | ||
| /// cancel that task first; this method does not cancel an in-flight write. | ||
| public func cancelAndWait() async { | ||
| await sourceCancellationBarrier.wait() | ||
| } | ||
|
|
||
| /// Enables would-block handling instead of parking the connection task. | ||
| private static func makeNonBlocking(_ socket: Int32) -> Int32? { | ||
| let flags = fcntl(socket, F_GETFL, 0) | ||
| guard flags >= 0 else { return errno } | ||
| guard fcntl(socket, F_SETFL, flags | O_NONBLOCK) >= 0 else { return errno } | ||
| return nil | ||
| } | ||
|
|
||
| /// Joins cancellation of a one-shot source on readiness or task cancellation. | ||
| /// - Parameter source: The inactive source for this socket; activation and | ||
| /// cancellation belong to this operation. | ||
| func waitForWritable(source writeSource: any DispatchSourceWrite) async -> Bool { | ||
| let stream = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) | ||
| let streamContinuation = stream.continuation | ||
| let sourceBox = SourceBox() | ||
| sourceCancellationBarrier.register() | ||
| writeSource.setEventHandler { [streamContinuation, sourceBox] in | ||
| streamContinuation.yield(()) | ||
| streamContinuation.finish() | ||
| sourceBox.source?.cancel() | ||
| } | ||
| writeSource.setCancelHandler { [sourceCancellationBarrier] in | ||
| streamContinuation.finish() | ||
| sourceCancellationBarrier.complete() | ||
| } | ||
| sourceBox.source = writeSource | ||
| writeSource.activate() | ||
|
|
||
| var iterator = stream.stream.makeAsyncIterator() | ||
| let writable: Void? = await withTaskCancellationHandler { | ||
| await iterator.next() | ||
| } onCancel: { | ||
| sourceBox.source?.cancel() | ||
| streamContinuation.finish() | ||
| } | ||
| // The event/cancellation handler may have resumed the iterator before | ||
| // libdispatch ran the source's cancellation handler. Await here while | ||
| // the source is still retained, so a subsequent socket close cannot | ||
| // race the kevent teardown. | ||
| writeSource.cancel() | ||
| await sourceCancellationBarrier.wait() | ||
| return writable != nil | ||
| } | ||
| } |
52 changes: 52 additions & 0 deletions
52
...muxControlSocket/Sources/CmuxControlSocket/Server/DispatchSourceCancellationBarrier.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| internal import Foundation | ||
|
|
||
| /// Completes asynchronous teardown after every registered dispatch source has | ||
| /// run its cancellation handler. | ||
| final class DispatchSourceCancellationBarrier: @unchecked Sendable { | ||
| // Lock carve-out: register() runs synchronously before source activation, | ||
| // while DispatchSource cancellation callbacks synchronously call complete(). | ||
| // Moving these updates into unstructured actor Tasks could reorder register | ||
| // and complete relative to wait(), letting teardown observe zero too early. | ||
| // This lock protects only the count and continuation handoff, never I/O or | ||
| // an await. Every continuation is removed under the lock and resumed after | ||
| // unlocking; all mutable state is locked, which justifies Sendable above. | ||
| private let lock = NSLock() | ||
| private var registrations = 0 | ||
| private var waiters: [CheckedContinuation<Void, Never>] = [] | ||
|
|
||
| /// Registers one source before it is activated. | ||
| func register() { | ||
| lock.lock() | ||
| registrations += 1 | ||
| lock.unlock() | ||
| } | ||
|
|
||
| /// Marks one source's cancellation handler complete. | ||
| func complete() { | ||
| lock.lock() | ||
| registrations -= 1 | ||
| let waiters = registrations == 0 ? self.waiters : [] | ||
| if registrations == 0 { | ||
| self.waiters.removeAll(keepingCapacity: false) | ||
| } | ||
| lock.unlock() | ||
| for waiter in waiters { | ||
| waiter.resume() | ||
| } | ||
| } | ||
|
|
||
| /// Suspends without blocking an executor until all sources have cancelled. | ||
| func wait() async { | ||
| await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in | ||
| lock.lock() | ||
| guard registrations > 0 else { | ||
| lock.unlock() | ||
| continuation.resume() | ||
| return | ||
| } | ||
| waiters.append(continuation) | ||
| lock.unlock() | ||
| } | ||
| } | ||
| } | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.