Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/watch-stop-clean-exit.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/js-sdk/src/sandbox/filesystem/watchHandle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Code, ConnectError } from '@connectrpc/connect'

import {
handleRpcErrorWithHealthCheck,
SandboxHealthCheck,
Expand Down Expand Up @@ -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<WatchDirResponse>,
Expand All @@ -91,6 +95,7 @@ export class WatchHandle {
* Stop watching the directory.
*/
async stop() {
this.stopped = true
this.handleStop()
}

Expand All @@ -104,6 +109,17 @@ export class WatchHandle {
}
}
} catch (err) {
if (
this.stopped &&

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.

T-46 — a caller-provided signal must still cancel an in-flight watch, with its failure surfaced to onExit. If opts.signal aborts first, then handle.stop() runs before the async iterator handles the resulting Code.Canceled, this.stopped is true and this branch silently calls onExit() as though stop caused the cancellation. That also hides the actionable timeout error when the caller uses AbortSignal.timeout(...) (T-64). Track the cause of the first abort, rather than whether stop() was ever called: e.g. let the cleanup callback report !controller.signal.aborted before it aborts, and only suppress Code.Canceled when that report says stop initiated the abort. Please cover signal-aborts-then-stop in the watch tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed against the current diff. stopped records whether stop() was called at any point, not which signal initiated the cancellation. If the caller’s signal aborts first and stop() follows before the iterator catches Code.Canceled, the error is incorrectly suppressed. The fix needs to record the first abort cause and treat only stop-initiated cancellation as a clean exit, with a regression for caller-abort-then-stop (including timeout signals). This remains outstanding in the current patch.

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)
}
}
Expand Down
68 changes: 68 additions & 0 deletions packages/js-sdk/tests/sandbox/files/watchHandle.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<void>((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

Expand Down