Skip to content

ControlSocket: end the proxy when either direction closes - #1383

Open
aatishparson-arker wants to merge 1 commit into
openai:mainfrom
aatishparson-arker:control-socket-propagate-half-close
Open

aatishparson-arker wants to merge 1 commit into
openai:mainfrom
aatishparson-arker:control-socket-propagate-half-close

Conversation

@aatishparson-arker

Copy link
Copy Markdown

The bug

ControlSocket.handleClient proxies the host's unix socket to the guest's vsock with two copy loops in one withThrowingDiscardingTaskGroup. Neither direction propagates a half-close.

When the guest closes its connection, vmInbound ends and that child returns normally — it does not throw, so the discarding group neither cancels its sibling nor completes. The sibling stays parked forever on clientInbound, waiting for bytes from a client that is itself waiting for the close to arrive. executeThenClose cannot close the client connection until the group returns, so a guest-initiated close is never observable by the client: the connection simply stays open until the client gives up on its own.

The same applies symmetrically if the client goes away first.

Each stranded session also leaves a parked child holding a connection on an event loop group built with numberOfThreads: 1.

How we hit it

We reach a macOS guest's agent over this socket. A terminal session's only close signal is EOF on its stream, so a customer's terminal never closed when their shell exited — it hung until their client timed out. Measured on 2.40.1: our host-side EOF path fired 0 times in 30 detaches, while every session ended through the client-side timeout branch.

The guest was closing correctly: it closes its single fd, and adding an explicit shutdown(SHUT_WR) before that close (so the FIN cannot depend on a last reference) changed nothing. The FIN reaches Tart and stops there.

ControlSocket.swift is byte-identical in 2.40.1, 2.41.0, 2.41.1 and main, so this is not a regression and no released version has it fixed.

The fix

Take the first child to finish and cancel the rest. This loses no data in either direction: a child finishes only once its inbound sequence is exhausted, so everything that side sent has already been written to the other.

Verification

handleClient can't be driven from a test — it dials a live VM through the global vm, and there is no existing test for the proxy — so I reproduced its exact structure over two socket pairs, carrying both shapes so the result doesn't depend on which one the source has. 5/5 runs, Swift 6.2.4 / macOS 26:

upstream (withThrowingDiscardingTaskGroup): client saw EOF = false
fixed    (first to finish + cancelAll):     client saw EOF = true

A sentinel byte through the proxy is a hard precondition in that harness, so "no EOF" cannot mean "the proxy never started". I'm happy to contribute that as a test if you'd like it in a form that fits your suite.

Then on real hardware, with a patched 2.41.1 build on an EC2 mac-m4.metal host: the two terminal-close tests that failed deterministically now pass, and our host-side EOF path fired twice — once per test — where it had fired zero times in thirty.

`handleClient` proxies the client's unix socket to the guest's vsock with two
copy loops in one `withThrowingDiscardingTaskGroup`. Neither direction
propagates a half-close.

When the guest closes its connection, `vmInbound` ends and that child returns
normally -- it does not throw, so the discarding group neither cancels its
sibling nor completes. The sibling stays parked on `clientInbound`, waiting for
bytes from a client that is itself waiting for the close to arrive, and
`executeThenClose` cannot close the client connection until the group returns.
So a guest-initiated close is never observable by the client: the connection
stays open until the client gives up on its own. The same holds symmetrically
when the client goes first, and each stranded session leaves a parked child
holding a connection on an event loop group built with `numberOfThreads: 1`.

Take the first child to finish and cancel the rest. This loses no data in
either direction: a child finishes only once its inbound sequence is
exhausted, so everything that side sent has already been written to the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T22:39:16.338430Z 0c512a4 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

// inbound sequence is exhausted, so everything that side sent has
// already been written to the other.
_ = try await group.next()
group.cancelAll()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This will discard buffered output.

NIO’s async write() can return before bytes reach the socket. Once the first copy loop finishes, cancelling its sibling lets executeThenClose close both channels, discarding pending writes.

Should be reproducible

This branch has not been deployed

No deployments
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