Skip to content

Fix attach session race that drops live frames#25

Open
jhgaylor wants to merge 1 commit into
superfly:mainfrom
jhgaylor:fix/attach-session-race-condition
Open

Fix attach session race that drops live frames#25
jhgaylor wants to merge 1 commit into
superfly:mainfrom
jhgaylor:fix/attach-session-race-condition

Conversation

@jhgaylor

@jhgaylor jhgaylor commented May 1, 2026

Copy link
Copy Markdown

Summary

WSCommand.start() creates the _run_io background task before calling _wait_for_session_info() on attach. Both coroutines then iterate async for message in self.ws against the same WebSocket. The websockets library does not support concurrent reads on a single connection — messages get split between the two coroutines unpredictably, and _wait_for_session_info silently drops any binary frames it happens to consume while waiting for the session_info text frame.

The visible symptom: a client that calls sprite.attach_session(id) reliably receives the replayed scrollback but loses subsequent live stdout/stderr/exit frames.

Fix

Drain _wait_for_session_info to completion before scheduling the _run_io task. There is then only ever one reader on the WebSocket at a time, and live binary frames are delivered in full.

-        self._io_task = asyncio.create_task(self._run_io())
-        await asyncio.sleep(0)
-
-        if self._is_attach:
-            await self._wait_for_session_info()
+        if self._is_attach:
+            await self._wait_for_session_info()
+
+        self._io_task = asyncio.create_task(self._run_io())
+        await asyncio.sleep(0)

Reproduction

Against a live sprite running bash -c 'for i in $(seq 1 30); do echo tick=$i; sleep 1; done':

Before — second client opened via sprite.attach_session(id) sees the replay and then nothing:

[reattached] tick=1
[reattached] tick=2
[reattached] tick=3
(no live ticks ever arrive on the reattached connection)

After — same setup, second client sees replay AND live frames in lockstep with the original exec:

[reattached] tick=1
[reattached] tick=2
[reattached] tick=3
[reattached] tick=4
[reattached] tick=5
...

A raw-WebSocket probe (bypassing the SDK) confirmed the server has always been streaming live frames to attach connections — the bug was purely client-side.

Test plan

  • All 24 existing tests in tests/ still pass (pytest tests/)
  • Verified against a live sprite end-to-end with sprite.attach_session(id) from the patched source
  • No new tests added — the existing suite has no WebSocket-level coverage and stubbing the websockets async iterator semantics would balloon scope; happy to add one if you'd like

WSCommand.start() was creating the _run_io background task BEFORE
calling _wait_for_session_info(). Both coroutines iterate
`async for message in self.ws`, but the websockets library does
not support concurrent reads on a single connection — messages are
split between the two coroutines unpredictably. _wait_for_session_info
silently drops any binary frames (stdout/stderr/exit) it consumes
while waiting for the session_info text frame, so attach clients
reliably receive the replayed scrollback but lose subsequent live
output.

Reorder so _wait_for_session_info runs to completion before the
_run_io task is scheduled. There is then only ever one reader on
the WebSocket at a time, and live binary frames are delivered in
full to attached clients.

Verified against a live sprite: a second client attached via
sprite.attach_session(id) now receives both the replay and the
ongoing tick stream in lockstep with the original exec connection,
where previously it only received the replay.
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