Skip to content

[Bug]: JS SDK: filesystem watch handle.stop() always fires onExit with a TimeoutError blaming 'requestTimeoutMs' #1895

Description

@BlueX888

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

  • e2b JS SDK 2.50.0, commit 6a608ef8770221283a8d307cedcd587a69213944 (current main)
  • 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

  1. added
    bugSomething isn't working
    sdkImprovements or additions to SDKs
    javascriptPull requests that update Javascript code
    on Sep 25, 2026
  2. devin-ai-integration commented on Sep 25, 2026

    @devin-ai-integration
    Contributor

    Triage

    • type: bug
    • priority: high
    • complexity: small
    • affected_area: js-sdk/filesystem watch handle (watchHandle.ts, envd/rpc.ts error mapping)
    • actionable: yes
    • needs_clarification: false
    • summary: In the JS SDK, a user-initiated WatchHandle.stop() aborts the Connect stream and the resulting Code.Canceled error is mapped to a TimeoutError blaming requestTimeoutMs and passed to onExit, 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 (a stopped flag consulted in handleEvents) are included.

    Written by Devin

  3. BlueX888 commented on Sep 25, 2026

    @BlueX888
    Author

    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:

    npx vitest run tests/sandbox/watchstop.repro.test.ts (in packages/js-sdk, with the repro test file from the issue body)
    

    Observed output:

    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
    

    Happy to open a PR with this fix, or to be assigned if you prefer to take it.

  4. added a commit that references this issue on Oct 1, 2026
    be2f23a
  5. added a commit that references this issue on Oct 1, 2026
    d0ed5c1
  6. devin-ai-integration commented on Oct 5, 2026

    @devin-ai-integration
    Contributor

    @BlueX888 thanks for reporting and reproducing this. The fix shipped in #1923 (squashed from #1912): WatchHandle.stop() now ends the watch cleanly, and onExit is called with no error instead of a TimeoutError blaming requestTimeoutMs. This matches the Python SDK.

    It's in the JS SDK e2b 2.52.1:

    Written by Devin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingjavascriptPull requests that update Javascript codesdkImprovements or additions to SDKs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions