Skip to content

fix(js-sdk): stop CommandHandle.disconnect() leaking the output subscription#1474

Open
mishushakov wants to merge 4 commits into
mainfrom
mishushakov/verify-bug-repro
Open

fix(js-sdk): stop CommandHandle.disconnect() leaking the output subscription#1474
mishushakov wants to merge 4 commits into
mainfrom
mishushakov/verify-bug-repro

Conversation

@mishushakov

@mishushakov mishushakov commented Jun 23, 2026

Copy link
Copy Markdown
Member

Description

This PR fixes two related issues in the command handle's event handling.

1. JS CommandHandle.disconnect() leaked the output subscription

disconnect() was fire-and-forget — it only triggered the transport abort and relied entirely on HTTP/2 abort propagation to stop events, which is unreliable under keepalive: onStdout/onStderr/onPty could keep firing for output produced after disconnect() returned.

disconnect() now sets a cooperative disconnected flag and aborts the transport. The flag is checked before every callback dispatch in the event loop, so once disconnect() returns no callback fires for output that arrives (or was buffered) after the call — even if the underlying abort hasn't torn the stream down yet. It does not wait for the event handler to drain, so it returns promptly even for an idle command (e.g. sleep) whose stream produces no further output, never blocks on an in-flight callback, and does not deadlock when awaited from inside a callback.

The async Python SDK was already correct here (disconnect() cancels the event-handling task), and the sync Python SDK has no background subscription (events are consumed only while the caller iterates). The added Python tests confirm both.

2. Exit code was lost when a disconnected consumer stopped on a flushed end-event chunk

When the end event flushes trailing decoder bytes (an incomplete multibyte sequence → replacement character) and the consumer stops iterating on the first flushed chunk, the generator was aborted before the result was assigned, so wait() failed as if the process never produced a result. The end handler now records the result before yielding the flushed chunks, across the JS, async Python, and sync Python SDKs.

Usage

const handle = await sandbox.commands.run(daemon, { background: true, stdin: true, onStdout })
await sandbox.commands.sendStdin(handle.pid, 'turn1\n')
await handle.disconnect() // resolves promptly; onStdout will not fire again
await sandbox.commands.sendStdin(handle.pid, 'turn2\n') // turn2 output never reaches onStdout

🤖 Generated with Claude Code

…ription

disconnect() now cooperatively ends event handling and resolves only after it
has fully stopped, so onStdout/onStderr/onPty are guaranteed not to fire for
output that arrives after the call resolves. An in-flight callback is abandoned
rather than awaited, preventing a deadlock when disconnect() is awaited from
inside a callback. Adds JS regression tests and Python tests verifying the
async/sync handles are already correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a8b072c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
e2b Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cla-bot cla-bot Bot added the cla-signed label Jun 23, 2026
@cursor

cursor Bot commented Jun 23, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Scoped to command stream subscription and disconnect semantics in the JS and Python SDKs, with no auth or data-path changes.

Overview
The JS CommandHandle.disconnect() could return while onStdout/onStderr/onPty still ran for output that arrived after the call, when the transport abort did not stop the stream immediately.

disconnect() now sets a disconnected flag and aborts via handleDisconnect() without waiting on the event loop, and the event handler skips callbacks once that flag is set. On stream end, the exit result is stored before any flushed decoder chunks are yielded so wait() still gets an exit code when iteration stops early after disconnect.

Python async and sync command handles get the same end-event ordering fix. Regression tests cover late output after disconnect, prompt disconnect on idle streams, and result recording after disconnect.

Reviewed by Cursor Bugbot for commit a8b072c. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from b4fc3bb. Download artifacts from this workflow run.

JS SDK (e2b@2.30.6-mishushakov-verify-bug-repro.0):

npm install ./e2b-2.30.6-mishushakov-verify-bug-repro.0.tgz

CLI (@e2b/cli@2.12.3-mishushakov-verify-bug-repro.0):

npm install ./e2b-cli-2.12.3-mishushakov-verify-bug-repro.0.tgz

Python SDK (e2b==2.29.5+mishushakov-verify-bug-repro):

pip install ./e2b-2.29.5+mishushakov.verify.bug.repro-py3-none-any.whl

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 168e31e7cc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/js-sdk/src/sandbox/commands/commandHandle.ts Outdated
Cast the controllable events fixture to the expected AsyncGenerator parameter
type; the stand-in is intentionally not a real async generator so its aclose()
does not unblock a suspended read, keeping the disconnect test discriminating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/js-sdk/src/sandbox/commands/commandHandle.ts Outdated
…the handler

Addresses review feedback: awaiting the event handler in disconnect() could
hang indefinitely for an idle command whose stream produces no further output
and whose transport abort does not unblock the pending read. The cooperative
disconnected flag, checked before every dispatch, already guarantees no
callback fires for post-disconnect output, so disconnect() no longer awaits the
handler. Adds an idle-stream regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2c8a348. Configure here.

Comment thread packages/js-sdk/src/sandbox/commands/commandHandle.ts
A disconnected JS consumer breaks out of handleEvents on the first chunk
yielded by iterateEvents. When an end event carried trailing decoder bytes, the
flush was yielded before this.result was assigned, so the break aborted the
generator and wait() failed with "Process exited without a result" even though
the process had exited. Record the result before yielding the flushed chunks.
Apply the equivalent reorder to the Python sync and async handles for parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude[bot]

This comment was marked as outdated.

@mishushakov mishushakov requested a review from huv1k June 23, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant