From 8c243380af6aab4c82aba53d9e2ff4263c0701ea Mon Sep 17 00:00:00 2001 From: BlueX888 <140241684+BlueX888@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:39:59 +0800 Subject: [PATCH] fix(js-sdk): treat a watch stopped via handle.stop() as a clean exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() aborts the watch request, which the transport surfaces as a canceled ConnectError — the same code a request timeout maps to. Every clean, user-initiated stop therefore fired onExit with a TimeoutError telling the user to raise 'requestTimeoutMs'. Recognize the cancellation stop() itself triggers and end the stream without an error, so onExit fires with none — matching the Python SDK, which treats stop() as a clean, user-initiated end. A cancellation from anywhere else (e.g. a request timeout aborting the signal) still fires onExit with the mapped error. --- .changeset/watch-stop-clean-exit.md | 5 ++ .../src/sandbox/filesystem/watchHandle.ts | 16 +++++ .../tests/sandbox/files/watchHandle.test.ts | 68 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 .changeset/watch-stop-clean-exit.md diff --git a/.changeset/watch-stop-clean-exit.md b/.changeset/watch-stop-clean-exit.md new file mode 100644 index 0000000000..16964546e7 --- /dev/null +++ b/.changeset/watch-stop-clean-exit.md @@ -0,0 +1,5 @@ +--- +"e2b": patch +--- + +Fire the filesystem watch `onExit` callback with no error when the watch is stopped with `handle.stop()`. Stopping a watch aborts the underlying request, which the transport surfaces as a canceled error — the same code a request timeout maps to — so every clean, user-initiated stop reported a `TimeoutError` telling the user to raise `requestTimeoutMs`. The cancellation `stop()` itself triggers is now treated as the clean end of the watch, matching the Python SDK; a cancellation from anywhere else (e.g. a request timeout) still fires `onExit` with the error. diff --git a/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts b/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts index cf16fc6a7e..6667f44d49 100644 --- a/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts +++ b/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts @@ -1,3 +1,5 @@ +import { Code, ConnectError } from '@connectrpc/connect' + import { handleRpcErrorWithHealthCheck, SandboxHealthCheck, @@ -77,6 +79,8 @@ export interface FilesystemEvent { * Use {@link WatchHandle.stop} to stop watching the directory. */ export class WatchHandle { + private stopped = false + constructor( private readonly handleStop: () => void, private readonly events: AsyncIterable, @@ -91,6 +95,7 @@ export class WatchHandle { * Stop watching the directory. */ async stop() { + this.stopped = true this.handleStop() } @@ -104,6 +109,17 @@ export class WatchHandle { } } } catch (err) { + if ( + this.stopped && + err instanceof ConnectError && + err.code === Code.Canceled + ) { + // stop() aborts the request, which the transport surfaces as a + // canceled error — the same code a request timeout maps to. That + // cancellation is the clean, user-initiated end of the watch, so end + // the stream without an error and let onExit fire with none. + return + } throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) } } diff --git a/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts b/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts index d4c4dc4f14..908146fb08 100644 --- a/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts +++ b/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { Code, ConnectError } from '@connectrpc/connect' + import { EventType } from '../../../src/envd/filesystem/filesystem_pb' +import { TimeoutError } from '../../../src/errors' import { FilesystemEventType, WatchHandle, @@ -110,6 +113,71 @@ describe('WatchHandle', () => { expect(received).toEqual([FilesystemEventType.WRITE]) }) + it('fires onExit with no error when stop() cancels the stream', async () => { + // stop() aborts the request, which the transport surfaces as a canceled + // ConnectError ("This operation was aborted"). The watch must treat that + // cancellation as the clean, user-initiated end — onExit with no error — + // not as the request-timeout error the canceled code maps to. + let abortStream: (() => void) | undefined + const streamAborted = new Promise((resolve) => { + abortStream = resolve + }) + + async function* canceledOnStop() { + yield filesystemEvent('a.txt') as any + await streamAborted + throw new ConnectError('This operation was aborted', Code.Canceled) + } + + let exitArgs: unknown[] | undefined + + const handle = new WatchHandle( + () => abortStream?.(), + canceledOnStop(), + () => {}, + (...args: unknown[]) => { + exitArgs = args + } + ) + + await handle.stop() + + await vi.waitFor(() => { + expect(exitArgs).toBeDefined() + }) + // A user-initiated stop is a clean end — onExit is called with zero args. + expect(exitArgs).toEqual([]) + }) + + it('fires onExit with the error when the stream is canceled without stop()', async () => { + // Only the cancellation stop() itself triggers is a clean end. A + // cancellation from anywhere else (e.g. a request timeout aborting the + // signal) is still an error. + async function* canceledStream() { + yield filesystemEvent('a.txt') as any + throw new ConnectError( + 'The operation was aborted due to timeout', + Code.Canceled + ) + } + + let exitErr: Error | undefined | 'unset' = 'unset' + + new WatchHandle( + () => {}, + canceledStream(), + () => {}, + (err) => { + exitErr = err ?? undefined + } + ) + + await vi.waitFor(() => { + expect(exitErr).not.toBe('unset') + }) + expect(exitErr).toBeInstanceOf(TimeoutError) + }) + it('does not let a throwing onExit become an unhandled rejection', async () => { let stopped = false