Skip to content

Fix two races that tear down a healthy socket - #26

Merged
colemancda merged 1 commit into
mainfrom
fix/lost-wakeup
Aug 7, 2026
Merged

Fix two races that tear down a healthy socket#26
colemancda merged 1 commit into
mainfrom
fix/lost-wakeup

Conversation

@colemancda

Copy link
Copy Markdown
Member

Fixes two races that tear a healthy socket down, both found by root-causing an intermittent ESHUTDOWN / ECONNABORTED on a freshly opened connection while running the pure-Swift D-Bus test suite. Before this change that suite failed roughly one run in five; after it, 11 consecutive full runs pass.

Both bugs share a shape: state is read at a different moment than the one the decision belongs to.

1. Lost wakeup in wait(for:fileDescriptor:)

wait suspends on a continuation, but registers it with the socket from a separate Task:

try await withThrowingContinuation(for: fileDescriptor) { continuation in
    Task(priority: .userInitiated) {
        await socket.queue(events, continuation)
    }
}

discard(_:detach:) drains the queue via dequeueAll(_:), also from a separate task. If those two interleave the wrong way, queue runs after dequeueAll has already drained everything, appending the continuation to a state that nothing will ever drain again. The caller then waits forever.

That is bad on its own — a permanently suspended task — but the real damage is that the stranded waiter keeps holding a file descriptor number. The descriptor itself is closed, so the kernel is free to hand the same number to the next socket, and the stale waiter then registers interest against whichever socket now owns it.

SocketState now records the teardown error, and queue resumes a late arrival with it immediately rather than enqueuing it.

Measured directly: with the D-Bus client instrumented to report when its read loop failed to stop after close(), this fired 4–7 times per test run, and every failing run was one where it fired.

2. A stale POLLHUP acted on after the socket connects

An unconnected socket reports POLLHUPSocket.init registers the descriptor with the event queue before connect, so there is a window where every socket polls as hung up.

The existing guard checked isEstablished before honoring a hangup, but it checked it in the task that runs after polling. So:

  1. Poll observes POLLHUP — the socket has not connected yet.
  2. connect completes and calls markEstablished().
  3. The deferred task reads isEstablished == true and tears the socket down.

The check cannot distinguish a stale observation from a real hangup, because by the time it runs the flag says "established" in both cases. The next operation on that socket then fails with ESHUTDOWN from socket(for:), or ECONNABORTED from the drained queue.

hangup(_:socket:) now re-polls the descriptor and only proceeds if it still reports a hangup. POLLHUP is level triggered, so a genuine hangup is reported again and is acted on exactly as before; only the stale observation is filtered.

Captured in the act, with the harmful event and the harmless ones side by side:

HUP-EVENT 7  established=true  listening=false events=[.hangup]                 <- tore down a healthy socket
HUP-EVENT 11 established=false listening=false events=[.read, .error, .hangup]  <- correctly skipped

The fd 7 line was immediately followed by the test failure. With the re-check in place the same event still occurs and is now correctly ignored.

Note

Neither fix ships with a regression test. Both are timing races between three tasks with no injection point to drive them deterministically, and a test that merely opens and closes sockets in a loop would reproduce them only probabilistically. They are verified by the D-Bus suite, which exercises the pattern heavily: 1-in-5 failures before, 11 consecutive clean runs after.

The pre-existing failures on this repo — the force unwrap at NetworkInterface.swift:58, and the fixed-port and fixed-path tests colliding with leftovers from earlier runs — are untouched here and fail the same way on main.

@colemancda
colemancda merged commit 1268209 into main Aug 7, 2026
37 checks passed
@colemancda
colemancda deleted the fix/lost-wakeup branch August 7, 2026 03:25
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.

1 participant