Repository navigation
Description
Sandbox ID or Build ID
N/A — no sandbox or template is involved. Reproduced deterministically against a local mock envd server speaking the Connect protocol, driving the same @connectrpc/connect-web transport the SDK uses.
Environment
e2bJS SDK 2.50.0, commit6a608ef8770221283a8d307cedcd587a69213944(currentmain)- Node.js v22.23.2, vitest 4.1.11, macOS 15 (arm64)
Timestamp of the issue
2026-09-25 18:51 UTC (deterministic — reproduces on every run)
Frequency
Happens every time
Expected behavior
await handle.stop() — the documented way to end a watch — should end the watch cleanly and call onExit with no argument. WatchOpts types the callback as onExit?: (err?: Error) => void | Promise<void>, "Callback to call when the watch operation stops" (packages/js-sdk/src/sandbox/filesystem/index.ts:340-343).
Basis: PR #1480 (commit de0c4016, "fix(sdk): correct filesystem watch handle callback and timeout behavior") added exactly this handling to the Python sibling in the same commit (packages/python-sdk/e2b/sandbox_async/filesystem/watch_handle.py:87-92):
except asyncio.CancelledError:
# `stop()` cancels this task to end the watch. Treat it as a clean,
# user-initiated end: fire `on_exit` (with no error), then propagate
# the cancellation so the task still finishes as cancelled.
await self._call_on_exit(None)That commit's changeset (.changeset/watch-handle-fixes.md) states the intended cross-SDK behavior: "Python (async): AsyncWatchHandle now invokes on_exit when the stream ends cleanly (with None) and when stop() is called, in addition to on error — matching the JS SDK." The JS half of the same commit rewrote watchHandle.ts (awaiting async callbacks, firing onExit exactly once) but never added the user-initiated-stop handling. The JS suite already asserts the clean-end contract for a server-initiated end ("calls onExit with no argument when the stream ends cleanly", packages/js-sdk/tests/sandbox/files/watchHandle.test.ts) — a user-initiated stop is no less clean.
Actual behavior
Every user-initiated stop() fires onExit with a TimeoutError that blames requestTimeoutMs. Observed independently by two verifiers, and confirmed again on a fresh clone at 6a608ef with the repro below — identical output each time:
exitErr.name: TimeoutError
exitErr.message: [canceled] This operation was aborted: This error is likely due to exceeding 'requestTimeoutMs'. You can pass the request timeout value as an option when making the request.
AssertionError: expected false to be true (at watchstop.repro.test.ts:129)
stack: watchHandle.ts:107 -> rpc.ts:146 -> rpc.ts:109 -> rpc.ts:81
A) onExit arg: TimeoutError: [canceled] This operation was aborted: This error is likely due to exceeding 'requestTimeoutMs'. You can pass the request timeout value as an option when making the request.
AssertionError: expected TimeoutError: [canceled] This operation was aborted … { statusCode: undefined } to be undefined
B) onExit arg: undefined
C) ConnectError code: 1 (canceled = 1)
Line B) is the control case: when the server ends the stream cleanly, onExit receives undefined — so the clean-end path already works; only the user-initiated stop() turns into a bogus timeout error.
Since stop() is the only documented way to end a watch, every JS SDK directory watch ends this way. Any user code that distinguishes a clean stop from a failure (e.g. onExit: (err) => { if (err) reportFatal(err) }) reports a fatal error on every ordinary, successful stop, and the message actively points users at raising requestTimeoutMs for a watch they themselves ended.
Issue reproduction
With a sandbox:
import { Sandbox } from 'e2b'
const sandbox = await Sandbox.create()
const handle = await sandbox.files.watchDir('/home/user', (event) => {
console.log('event:', event.type, event.name)
}, {
onExit: (err) => console.log('onExit:', err ?? '(clean end)'),
})
await handle.stop() // the documented way to end the watch
// → onExit: TimeoutError: [canceled] This operation was aborted: This error is
// likely due to exceeding 'requestTimeoutMs'. You can pass the request timeout
// value as an option when making the request.Deterministic, sandbox-free reproduction: the real createConnectTransport from @connectrpc/connect-web driven against a local mock envd speaking the Connect JSON protocol. Save as tests/sandbox/watchstop.repro.test.ts inside packages/js-sdk and run npx vitest run tests/sandbox/watchstop.repro.test.ts:
tests/sandbox/watchstop.repro.test.ts (run against commit 6a608ef)
/**
* Repro: WatchHandle.stop() — clean, user-initiated exit, or an error?
*
* The Python SDK treats stop() as a clean exit (on_exit(None), added in the
* same commit de0c4016 that fixed the JS watch handle). The JS SDK has no such
* handling — this repro drives the real connect transport against a mock envd
* to see what onExit receives when the user calls stop().
*/
import { describe, expect, it } from 'vitest'
import http from 'node:http'
import { createConnectTransport } from '@connectrpc/connect-web'
import { Filesystem } from '../../src/sandbox/filesystem/index'
import { EnvdApiClient } from '../../src/envd/api'
import { ConnectionConfig } from '../../src/connectionConfig'
function frame(flags: number, payload: string): Buffer {
const body = Buffer.from(payload)
const head = Buffer.alloc(5)
head.writeUInt8(flags, 0)
head.writeUInt32BE(body.length, 1)
return Buffer.concat([head, body])
}
type Stream = {
send: (obj: unknown) => void
end: () => void
}
function startMockEnvd(routes: Record<string, (res: http.ServerResponse) => Stream>) {
const requests: Array<{ path: string; headers: http.IncomingHttpHeaders }> = []
const server = http.createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
requests.push({ path: req.url ?? '', headers: req.headers })
const handler = routes[(req.url ?? '').split('?')[0]]
if (!handler) {
res.writeHead(404)
res.end()
return
}
handler(res)
})
})
return new Promise<{ port: number; requests: typeof requests; close: () => Promise<void> }>(
(resolve) => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as { port: number }
resolve({
port,
requests,
close: () => new Promise((done) => server.close(() => done())),
})
})
}
)
}
function streamRoute(res: http.ServerResponse): Stream {
res.writeHead(200, { 'content-type': 'application/connect+json' })
return {
send: (obj) => res.write(frame(0x00, JSON.stringify(obj))),
end: () => {
res.write(frame(0x02, JSON.stringify({})))
res.end()
},
}
}
describe('WatchHandle.stop() repro', () => {
it('stop() fires onExit cleanly (no error)', async () => {
let watchStream: Stream | undefined
const mock = await startMockEnvd({
'/filesystem.Filesystem/WatchDir': (res) => {
watchStream = streamRoute(res)
// Send the start event; keep the stream open afterwards.
watchStream.send({ start: {} })
return watchStream
},
})
const transport = createConnectTransport({
baseUrl: `http://127.0.0.1:${mock.port}`,
useBinaryFormat: false,
})
const envdApi = new EnvdApiClient(
{ apiUrl: `http://127.0.0.1:${mock.port}` },
{ version: '0.6.4' }
)
const files = new Filesystem(transport, envdApi, new ConnectionConfig({}))
const events: string[] = []
let exitErr: Error | undefined | 'unset' = 'unset'
let cleanExit = false
const handle = await files.watchDir('dir', (e) => {
events.push(e.name)
}, {
onExit: (err) => {
if (err === undefined) cleanExit = true
exitErr = err ?? undefined
},
})
// Deliver one filesystem event so the watch is established and streaming.
watchStream!.send({
filesystem: { name: 'a.txt', type: 'EVENT_TYPE_WRITE' },
})
await new Promise((r) => setTimeout(r, 100))
expect(events).toEqual(['a.txt'])
// User-initiated stop.
await handle.stop()
await new Promise((r) => setTimeout(r, 300))
console.log('cleanExit:', cleanExit)
console.log('exitErr:', exitErr === 'unset' ? 'NOT CALLED' : exitErr)
if (exitErr !== 'unset' && exitErr !== undefined) {
console.log('exitErr.name:', exitErr.name)
console.log('exitErr.message:', exitErr.message)
}
// Python SDK (same fix commit de0c4016): stop() => on_exit(None).
// Expect the same here.
expect(cleanExit).toBe(true)
await mock.close()
}, 10_000)
})Additional context
Root cause. WatchHandle.stop() only calls the abort cleanup — it sets nothing distinguishing a user-initiated stop (packages/js-sdk/src/sandbox/filesystem/watchHandle.ts:93-95). handleStop is the cleanup from setupRequestController (packages/js-sdk/src/sandbox/filesystem/index.ts:1076, passed as the first ctor arg at :1102-1103), which calls controller.abort() (packages/js-sdk/src/connectionConfig.ts:195-200) on the signal given to the transport (index.ts:1093). The aborted connect stream surfaces as ConnectError with Code.Canceled; iterateEvents rethrows it through handleRpcErrorWithHealthCheck (watchHandle.ts:106-108), whose default error map turns Code.Canceled into a TimeoutError blaming requestTimeoutMs (packages/js-sdk/src/envd/rpc.ts:80-83), and handleEvents then passes that error to onExit (watchHandle.ts:132-143).
Related. #1480 (the commit that established the intended behavior on the Python side of this same fix). Tangentially related: #463 (no way to tell timeout kinds apart). I searched open and closed issues for watch/onExit/stop reports and found none covering this.
Happy to open a PR mirroring the Python fix — e.g. a stopped flag set in stop() before handleStop(), so that when the stream subsequently ends with the user-initiated cancellation (Code.Canceled), handleEvents treats it as a clean end and calls await this.onExit?.() instead of routing it through the error map; genuine stream failures (deadline exceeded, unavailable, sandbox killed) keep the current error path.
Activity
devin-ai-integration commentedon Sep 25, 2026
Triage
watchHandle.ts,envd/rpc.tserror mapping)WatchHandle.stop()aborts the Connect stream and the resultingCode.Cancelederror is mapped to aTimeoutErrorblamingrequestTimeoutMsand passed toonExit, instead of treating the stop as a clean end (onExit()with no error) as the Python SDK already does since fix(sdk): correct filesystem watch handle callback and timeout behavior #1480; a deterministic mock-envd repro and a proposed fix (astoppedflag consulted inhandleEvents) are included.Written by Devin
BlueX888 commentedon Sep 25, 2026
Reproduced this. Root cause: packages/js-sdk/src/sandbox/filesystem/watchHandle.ts:93-95 (pre-fix) — stop() only aborts the request and sets nothing marking the stop as user-initiated, so the canceled ConnectError it triggers is mapped by packages/js-sdk/src/envd/rpc.ts:80-83 (Code.Canceled -> TimeoutError blaming 'requestTimeoutMs') and passed to onExit.
Minimal reproduction:
Observed output:
Happy to open a PR with this fix, or to be assigned if you prefer to take it.
fix(js-sdk): treat WatchHandle.stop() as a clean watch end (#1912)
fix(js-sdk): treat WatchHandle.stop() as a clean watch end (#1923)
devin-ai-integration commentedon Oct 5, 2026
@BlueX888 thanks for reporting and reproducing this. The fix shipped in #1923 (squashed from #1912):
WatchHandle.stop()now ends the watch cleanly, andonExitis called with no error instead of aTimeoutErrorblamingrequestTimeoutMs. This matches the Python SDK.It's in the JS SDK
e2b2.52.1:Written by Devin