Skip to content

feat: add file watching capabilities with inotify support#324

Merged
ghostwriternr merged 38 commits intomainfrom
feature/file-watching
Mar 3, 2026
Merged

feat: add file watching capabilities with inotify support#324
ghostwriternr merged 38 commits intomainfrom
feature/file-watching

Conversation

@whoiskatrin
Copy link
Copy Markdown
Collaborator

@whoiskatrin whoiskatrin commented Jan 2, 2026

User problems this solves

This feature is designed for concrete workflows, not just API surface:

  1. Hot reload / dev servers — Trigger rebuilds or reloads when source files change.
  2. Incremental indexing / analysis — Re-index only changed files instead of rescanning whole trees.
  3. Test reruns — Re-run affected tests when test files or dependencies change.

API

import { parseSSEStream } from "@cloudflare/sandbox";
import type { FileWatchSSEEvent } from "@cloudflare/sandbox";
 
// watch() resolves only after the watcher is established on the filesystem.
// Callers can immediately perform actions that depend on the watch being active.
const stream = await sandbox.watch("/app/src", {
  recursive: true,
  include: ["*.ts", "*.js"],
});
 
// Consume the SSE stream with parseSSEStream
const controller = new AbortController();
for await (const event of parseSSEStream<FileWatchSSEEvent>(
  stream,
  controller.signal
)) {
  if (event.type === "event") {
    console.log(event.eventType, event.path);
    // e.g. "create" "/app/src/new-file.ts"
  }
}
 
// Cancel by aborting the signal — cleans up the watcher server-side
controller.abort();

Key behaviors

  • Deterministic readiness: watch() blocks until the filesystem watcher is established. No need to scan for a watching event before performing actions.
  • Stream-based: Returns ReadableStream<Uint8Array> consumed via parseSSEStream. This is the only viable approach given the RPC layer (AsyncIterable cannot cross the boundary).
  • Cancellation via AbortSignal: parseSSEStream registers an abort listener that calls reader.cancel(), which propagates server-side cleanup. No explicit stop endpoint needed.
  • include/exclude are mutually exclusive: Validated at the container layer since inotifywait does not support both simultaneously.

Event flow

inotifywait (container)
        ↓
WatchService emits SSE frames
        ↓
parseSSEStream yields typed events
        ↓
User iterates with for-await

Hardening included in this PR

  • Watch setup correctness — Fails watch startup on setup timeout/error (does not emit false watching). watch() throws if the watcher fails to establish.
  • Request validation — Validates path, recursive, include, exclude. Rejects both include and exclude together.
  • Path safety — Normalizes watch paths and enforces /workspace boundary.
  • Backpressure protection — Coalesces events and caps pending queue at 1000; logs dropped-event pressure.
  • Stream cancellation cleanup — Client-side reader.cancel() or AbortSignal abort propagates to kill the inotifywait process server-side. No orphaned watchers.
  • WS per-connection stream ownershiponClose only cancels streams belonging to the closing WebSocket connection, not all active streams.
  • Glob behavior clarity — Supported syntax is intentionally limited (*, **, ?, path separators). Unsupported complex tokens are rejected via validation.

Reviewer notes

1) SDK watch client + readiness

  • packages/sandbox/src/clients/watch-client.ts
  • packages/sandbox/src/sandbox.ts
  • Focus: waitForReadiness() reads chunks until watching event, returns wrapper stream that replays buffered data

2) SSE parser abort handling

  • packages/sandbox/src/sse-parser.ts
  • Focus: abort listener calls reader.cancel() to unblock idle streams; cleanup removes listener

3) Container watch routing/validation

  • packages/sandbox-container/src/handlers/watch-handler.ts
  • packages/sandbox-container/src/routes/setup.ts
  • Focus: request validation, include/exclude mutual exclusivity, path normalization

4) Watch process + stream robustness

  • packages/sandbox-container/src/services/watch-service.ts
  • Focus: setup-timeout behavior, backpressure controls, stderr/watch process cleanup

5) WebSocket adapter stream ownership

  • packages/sandbox-container/src/handlers/ws-adapter.ts
  • Focus: per-connection activeStreams tracking, scoped cancellation on close

6) End-to-end coverage

  • tests/e2e/file-watch-workflow.test.ts
  • tests/e2e/test-worker/index.ts
  • Focus: watch establishment, include/exclude, stream cancellation, error paths. Uses parseSSEStream + AbortSignal.timeout with no hardcoded delays.

Key design decisions

  1. Stream-based, not handle-based — Raw SSE stream + parseSSEStream is the only approach that works across the RPC boundary. No onEvent callback or watcher.stop().
  2. Deterministic readinesswatch() blocks until established so callers know the watcher is active before performing filesystem actions.
  3. Cancellation via signal, not stop endpoint — Aborting the AbortSignal or canceling the stream reader propagates cleanup. No /api/watch/stop needed.
  4. Operationally safe defaults — Timeouts, cleanup, bounded buffering (max 1000 pending events).
  5. Explicit validation and constrained pattern support — Predictable behavior over silent surprises.'

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jan 2, 2026

🦋 Changeset detected

Latest commit: 6b88d36

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

This PR includes changesets to release 1 package
Name Type
@cloudflare/sandbox 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

Comment thread packages/sandbox-container/src/services/watch-service.ts Fixed
agents-git-bot Bot pushed a commit to cloudflare/cloudflare-docs that referenced this pull request Jan 2, 2026
Documents the new sandbox.watch() method that enables real-time filesystem
monitoring using Linux inotify. Includes:

- API reference for watch() method in files.mdx
- Comprehensive how-to guide with practical examples
- Cross-references between related documentation

This documentation covers:
- Basic file watching with callbacks
- Filtering by file type with glob patterns
- Event type filtering (create, modify, delete, rename)
- Cancellation with AbortController
- Hot reload and log monitoring examples
- Best practices and troubleshooting

Synced from cloudflare/sandbox-sdk#324
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jan 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/sandbox-sdk/@cloudflare/sandbox@324

commit: 6b88d36

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jan 2, 2026

🐳 Docker Images Published

Default:

FROM cloudflare/sandbox:0.0.0-pr-324-ff19085

With Python:

FROM cloudflare/sandbox:0.0.0-pr-324-ff19085-python

With OpenCode:

FROM cloudflare/sandbox:0.0.0-pr-324-ff19085-opencode

Version: 0.0.0-pr-324-ff19085

Use the -python variant if you need Python code execution, or -opencode for the variant with OpenCode AI coding agent pre-installed.


📦 Standalone Binary

For arbitrary Dockerfiles:

COPY --from=cloudflare/sandbox:0.0.0-pr-324-ff19085 /container-server/sandbox /sandbox
ENTRYPOINT ["/sandbox"]

Download via GitHub CLI:

gh run download 22590042787 -n sandbox-binary

Extract from Docker:

docker run --rm cloudflare/sandbox:0.0.0-pr-324-ff19085 cat /container-server/sandbox > sandbox && chmod +x sandbox

claude[bot]

This comment was marked as outdated.

claude[bot]

This comment was marked as outdated.

claude[bot]

This comment was marked as outdated.

@whoiskatrin whoiskatrin marked this pull request as ready for review January 2, 2026 19:55
@whoiskatrin whoiskatrin marked this pull request as draft January 2, 2026 20:57
claude[bot]

This comment was marked as outdated.

claude[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as duplicate.

github-actions[bot]

This comment was marked as outdated.

@whoiskatrin whoiskatrin marked this pull request as ready for review January 15, 2026 08:09
@parssak
Copy link
Copy Markdown

parssak commented Feb 7, 2026

hey! I noticed there are docs for this but doesn't seem like the changes are live yet

would love to use this API, any timelines on when this'll get merged?

@whoiskatrin
Copy link
Copy Markdown
Collaborator Author

/bonk resolve conflict and review this PR

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Merge Conflict Resolved + Code Review

Conflict Resolution

Resolved the merge conflict in packages/sandbox-container/src/core/container.ts. The conflict was between the feature branch's watchHandler and main's ptyWsHandler — both are needed, so I included both in the Dependencies interface, the initialize() method, and the dependency registration block.

Build/typecheck/lint/unit tests all pass after the merge.


Code Review

Overall this is a well-architected feature that follows the three-layer SDK pattern correctly. The SSE streaming approach, DI integration, client pattern, and type safety are all solid. There are however several issues worth addressing, roughly in order of priority.

Issues to Address Before Merge

1. Empty catch blocks in cleanup paths (multiple locations)

Several catch blocks silently swallow errors that could indicate real problems:

  • watch-service.ts:390-393proc.kill() in the finally block catches everything with just a comment "Process may already be dead". This hides permission errors (EPERM), system failures, etc. At minimum log at debug level and distinguish ESRCH (expected) from other errors.

  • watch-service.ts:477 — Controller enqueue failure caught silently. Could lose error messages from inotifywait.

  • watch-service.ts:563 — Same pattern in continueStderrMonitoring.

  • file-watch.ts:168this.reader.cancel().catch(() => {}) in the finally block.

Per AGENTS.md: "silent failures are unacceptable." These should at minimum log at debug level.

2. FileWatch.established() can hang forever with AbortSignal

file-watch.ts:115-129 — If the watch is cancelled via AbortSignal during establishment before receiving the "watching" event, the runLoop throws and calls establishedReject, which is correct. However, if abort fires after established() is called but before the runLoop picks it up, there's a window where the promise could hang. The runLoop does check signal.aborted in the while condition, but if reader.read() is blocked waiting for data, the abort won't interrupt it until data arrives or the stream ends.

Consider adding a timeout or connecting the abort signal to reject the establishment promise directly.

3. isFileWatchSSEEvent type guard is weak

file-watch.ts:175-180 — Only validates that type is a string in the allowed set, but doesn't validate that required fields exist for each event type. For event type events, eventType, path, and isDirectory could all be undefined, leading to runtime errors or silent data loss when passed to onEvent callback.

4. Changeset description is implementation-focused

Per AGENTS.md: changesets should be user-facing with clear benefit and usage example. Current:

feat: add file watching capabilities with inotify support

Should explain benefit and show usage:

Add real-time file watching for detecting filesystem changes as they happen.

Use `sandbox.watch()` to monitor directories for create, modify, delete, and rename events:
const watcher = await sandbox.watch('/app/src', {
  recursive: true,
  onEvent: (event) => console.log(event.type, event.path),
});
await watcher.stop();

5. Debug logging left in E2E tests

file-watch-workflow.test.ts:204,257,404-408,436,451-452,485-488 — Multiple console.log('[DEBUG]...') statements should be removed before merge. Other E2E tests in the codebase don't have debug logging.

6. stopWatch uses UNKNOWN_ERROR instead of specific code

watch-service.ts:114 — When process.kill() fails in stopWatch(), it returns ErrorCode.UNKNOWN_ERROR. The codebase convention is to use specific error codes (like PROCESS_NOT_FOUND, PORT_NOT_EXPOSED). Consider adding WATCH_STOP_ERROR or reusing WATCH_START_ERROR (since it's a watch lifecycle error).

7. WatchService not integrated into server shutdown

The WatchService spawns inotifywait processes but there's no cleanup hook in packages/sandbox-container/src/server.ts. When the container shuts down, these processes could become zombies. Add watchService.stopAllWatches() to the server cleanup sequence.

Minor Items

  • watch-service.ts:131-137 — Comments reference historical context ("Expected case: process already exited"). Per AGENTS.md, describe current behavior instead of referencing what the code prevents.

  • E2E tests use fixed delays (1500ms, 3000ms) that could cause flakiness in slower CI environments. The watchWithActions helper's delays should be configurable or event-driven.

  • file-watch.ts:189-191 — Malformed JSON is silently ignored with no logging. This makes debugging "missing events" very difficult in production. Consider logging at debug level.

Strengths

  • Secure glob matching: Character-by-character regex construction with comprehensive ReDoS prevention tests is exemplary.
  • State machine design in FileWatch: Clean establishing → active → stopped transitions with proper error handling at each transition.
  • WatchService follows established patterns: DI via constructor injection, ServiceResult<T> return type, proper logger child creation.
  • Comprehensive unit tests: Good coverage of glob matching, inotify event parsing, and argument building. Uses createNoOpLogger() as documented.
  • E2E tests cover real workflows: Creation, modification, deletion, filtering, stop/list APIs, and error cases.
  • Proper error codes: WATCH_NOT_FOUND and WATCH_START_ERROR with correct HTTP status mappings.
  • WebSocket transport improvements: Partial SSE event state tracking across chunks is a correct fix for chunked event parsing.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Feb 10, 2026

Done. Here's a summary of what I did:

Merge Conflict Resolution

Resolved the single conflict in packages/sandbox-container/src/core/container.ts where the feature branch added WatchHandler and main added PtyWebSocketHandler. Both were included in the merged result. The merge commit has been pushed.

Verification

  • Biome lint: clean (295 files checked, no issues)
  • TypeScript typecheck: all 14 packages pass
  • Unit tests: all pass across all packages

Review Summary

The PR adds a well-architected file watching feature using inotify that follows the three-layer SDK pattern correctly. Key strengths include secure glob matching with ReDoS prevention, a clean state machine in FileWatch, and comprehensive test coverage.

7 issues identified ranging from silent error swallowing in cleanup paths, a potential hang in FileWatch.established() with AbortSignal, a weak type guard, implementation-focused changeset description, debug logging left in E2E tests, a generic error code where a specific one should be used, and missing server shutdown cleanup for watch processes. The full review is posted on the PR.

github run

github-actions[bot]

This comment was marked as outdated.

@whoiskatrin
Copy link
Copy Markdown
Collaborator Author

hey! I noticed there are docs for this but doesn't seem like the changes are live yet

would love to use this API, any timelines on when this'll get merged?

really keen to get this in this week or next week. sorry about the docs, someone got them merged ahead of time

Copy link
Copy Markdown
Member

@ghostwriternr ghostwriternr left a comment

Choose a reason for hiding this comment

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

Biggest issue is perhaps confirming the API usage works as expected + some minor issues

Comment thread tests/e2e/test-worker/index.ts Outdated
Comment thread packages/sandbox-container/src/handlers/ws-adapter.ts
Comment thread packages/sandbox/src/clients/transport/ws-transport.ts
Comment thread packages/sandbox-container/src/handlers/ws-adapter.ts Outdated
Comment thread packages/sandbox-container/src/services/watch-service.ts
Comment thread packages/sandbox-container/src/services/watch-service.ts Outdated
github-actions[bot]

This comment was marked as outdated.

Remove /api/watch/stop endpoint and WatchClient.stop() method as watch
lifecycle is now managed through handle-based API. Clean up verbose
debug logging in WebSocket stream handler, keeping only completion
summary. Clear setup timeout in watch service to prevent leak.
github-actions[bot]

This comment was marked as outdated.

Track connectionId for each active stream and only allow cancellation from
the connection that initiated it. Prevent cross-connection cancel messages
and ensure onClose only cancels streams owned by the closing connection.
github-actions[bot]

This comment was marked as outdated.

Copy link
Copy Markdown
Member

@ghostwriternr ghostwriternr left a comment

Choose a reason for hiding this comment

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

I'm focusing this round of comments entirely on the E2E test - which as you've noticed is where I'm starting reviews for all new API surfaces - because it's the closest sign of how users would be experiencing the API. In this particular case, we seem to be doing a ton of extra work in the test layer because our SDK doesn't make it easy for the user. Furthermore, a lot of these issues also become apparent the moment I try to use the new APIs on the minimal example worker and notice there is no easy way to exit out of the watching loop because there is no easy way provided by the SDK to deterministically identify the terminal state.

The changes encouraged by the comments below would ideally need underlying changes - most prominently to our sse stream handling helper - and make user code cleaner + our tests deterministic and not flaky.

/**
* Helper to start a watch that allows performing actions after the watch is established.
*/
async function watchWithActions<T>(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This helper is doing a very heavy lift here in place of the SDK. It is currently manually parsing SSE, manages reader lifecycle, detects readiness, handles timeouts, and collects typed events - all things parseSSEStream + watch() should provide out of the box.

I'd encourage comparing this to how something like execStream works: users call sandbox.execStream(), pipe through parseSSEStream(), and iterate typed events with for await, which is ~5 lines. The equivalent for watch() is users needing to write some version of this 90-line function.

If the goal is to provide the DX the original PR description promises (which also needs updating since the API surface has changed a bit due to the RPC constraints), watch() should return something users can consume with for await and an abort signal, just like execStream + parseSSEStream.

Comment thread tests/e2e/file-watch-workflow.test.ts Outdated
const events: FileWatchSSEEvent[] = [];
let watchId: string | null = null;
let actionResult: T;
const reader = response.body.getReader();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Our SDK currently exports parseSSEStream and even the JSDoc on the new watch() method recommends using it. But this test can't, because parseSSEStream doesn't currently support cancellation of long-lived streams. Its abort check only runs between reader.read() calls and on an idle watch stream, reader.read() blocks indefinitely, so the signal is never checked.

So maybe the hint here is to get parseSSEStream to register an abort listener that calls reader.cancel() when the signal fires, rather than polling signal.aborted between reads. It's probably just a one-line fix (signal.addEventListener('abort', () => reader.cancel())) (pls test properly though, I haven't tried this yet) that would make the entire watchWithActions helper unnecessary.

Comment thread tests/e2e/file-watch-workflow.test.ts Outdated
Comment on lines +93 to +124
// Use a cancellable timeout that resets after actions complete.
// The initial timeout covers watch establishment; after actions run,
// a fresh timeout gives the full timeoutMs for events to arrive.
let timeoutHandle = setTimeout(() => reader.cancel(), timeoutMs);

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';

for (const line of lines) {
if (line.startsWith('data: ')) {
const event = JSON.parse(line.slice(6)) as FileWatchSSEEvent;
events.push(event);

if (event.type === 'watching') {
watchId = event.watchId;
if (!actionsExecuted) {
actionsExecuted = true;
// Pause the timeout while executing actions so the full
// timeoutMs window is available for collecting events afterward
clearTimeout(timeoutHandle);
await new Promise((r) => setTimeout(r, 1500));
actionResult = await actions();
// Give events time to propagate through the SSE stream
await new Promise((r) => setTimeout(r, 3000));
// Reset the timeout so we have the full window to read events
timeoutHandle = setTimeout(() => reader.cancel(), timeoutMs);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a manual reimplementation of AbortSignal.timeout() that this test has to do because passing AbortSignal.timeout(5000) to parseSSEStream doesn't actually cancel the stream (due to reasons I shared above). The "cancellable + resettable timeout" pattern is a major footgun because every user building a watch UI will need to solve the same "how do I stop listening after N seconds of inactivity" problem.

Once parseSSEStream handles abort correctly, this likely just becomes AbortSignal.timeout(timeoutMs) and need zero custom code.

Comment thread tests/e2e/file-watch-workflow.test.ts Outdated
Comment on lines +112 to +122
if (event.type === 'watching') {
watchId = event.watchId;
if (!actionsExecuted) {
actionsExecuted = true;
// Pause the timeout while executing actions so the full
// timeoutMs window is available for collecting events afterward
clearTimeout(timeoutHandle);
await new Promise((r) => setTimeout(r, 1500));
actionResult = await actions();
// Give events time to propagate through the SSE stream
await new Promise((r) => setTimeout(r, 3000));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As per the latest design, watch resolves immediately with a raw ReadableStream before the watcher is established on the filesystem. So this test has to manually scan the SSE stream for the watching event to know it's safe to create files — which is a bit absurd.

IMO, watch should either not resolve until the watcher is established (like how exec doesn't resolve until the command finishes), so that users don't need to write their own readiness-detection loop.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any time we are using magic timeouts, that's a big code/design smell imo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In fact, the more I'm reading this test file, I'm noticing there are 6 different hardcoded delays, all compensating for the lack of a readiness signal and event delivery guarantees. This 1500ms pre-action sleep is especially telling because the test already received the watching event but still doesn't trust that the watcher is ready. And the 3000ms one suggests we don't have a way to even confirm event delivery?

These are the kind of things that don't just make our CI flaky, but also user production code.

// Start watch and create files after watch is confirmed ready
const { events } = await watchWithActions(
testDir,
{ include: ['*.ts'], timeoutMs: 10000, stopAfterEvents: 10 },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This comment applies here, as well as the should exclude patterns from events test below.

These two tests carefully avoid combining both include and exclude. And the reason is because the test worker has this line:

exclude: body.include ? [] : body.exclude,

The test worker silently drops exclude whenever include is present because inotifywait doesn't support both simultaneously. But WatchOptions accepts both include and exclude and nothing in the type specification or elsewhere tells users they're mutually exclusive.

We should either make the type honest: use a discriminated union or overloads so include and exclude can't be specified together. OR - and this is what I'd prefer - implement the combination at the SDK/container layer by maybe filtering in the SSE emitter if inotify can't handle it.

We shouldn't be hiding these behaviours in the test worker.

Comment thread tests/e2e/file-watch-workflow.test.ts Outdated
Comment on lines +345 to +355
const reader = response.body.getReader();
const decoder = new TextDecoder();

// Wait for watch to establish
let watchingReceived = false;
while (!watchingReceived) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
watchingReceived = text.includes('"type":"watching"');
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One more sign of even the simplest test to start a watch, then stop it can't use parseSSEStream. It has to manually read chunks and do string matching instead of iterating typed events. Further evidence that parseSSEStream's abort handling is the cleanest place to improve things across the board.

Comment thread tests/e2e/file-watch-workflow.test.ts Outdated
Comment on lines +381 to +421
// Should get an error response (either 4xx/5xx or error in body)
if (!response.ok) {
const body = await response.text();
expect(body).toMatch(
/error|not found|does not exist|permission denied|file_not_found|permission_denied/i
);
return;
}

// If we get a 200, it should be an SSE stream with an error event
// Over WebSocket transport, errors may throw instead of returning data
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
let streamError: Error | null = null;

// Read multiple chunks in case the error comes later
const timeout = setTimeout(() => reader.cancel(), 3000);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
// Check if we have an error indication
if (text.match(/error|not found|does not exist/i)) {
break;
}
}
} catch (e) {
// Stream error or cancelled - capture the error
streamError = e instanceof Error ? e : new Error(String(e));
} finally {
clearTimeout(timeout);
reader.cancel().catch(() => {});
}

// Should contain an error message in either the text or the stream error
const errorContent = text || streamError?.message || '';
expect(errorContent).toMatch(/error|not found|does not exist/i);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test checks for errors via HTTP status code, SSE stream content and stream exception. If this is intentional and necessary, a user calling sandbox.watch('/nonexistent') has to defend against all three paths and maybe a hint at the error handling story for watch not being properly done too.

…e validation

- Fix parseSSEStream to register abort listener that calls reader.cancel(),
  unblocking idle streams instead of polling signal.aborted between reads
- Make watch() block until the watching event is received so callers can
  immediately perform actions that depend on the watcher being active
- Reject requests that specify both include and exclude with a clear
  validation error since inotifywait does not support both simultaneously
- Add timeout-race tests for WebSocket transport stream requests
- Simplify E2E watch helper: use parseSSEStream + AbortSignal.timeout,
  remove all hardcoded delays
- Remove test worker workaround that silently dropped exclude when
  include was present
github-actions[bot]

This comment was marked as outdated.

watch() now throws on establishment failure, so the test worker
returns a clear error response. The 3-path defensive logic is
no longer necessary.
@whoiskatrin
Copy link
Copy Markdown
Collaborator Author

/bonk check if I missed anything based on Naresh's last review

github-actions[bot]

This comment was marked as outdated.

@cloudflare cloudflare deleted a comment from ask-bonk Bot Feb 27, 2026
watch() now blocks until established, so the manual loop scanning
for the watching event is redundant. Read one chunk to confirm
the stream is live, then cancel.
github-actions[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

Copy link
Copy Markdown
Member

@ghostwriternr ghostwriternr left a comment

Choose a reason for hiding this comment

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

very minor comments, looks good overall

Comment thread .changeset/empty-poets-serve.md Outdated
import type { WatchService } from '../services/watch-service';
import { BaseHandler } from './base-handler';

const WORKSPACE_ROOT = '/workspace';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should maybe use CONFIG.DEFAULT_CWD, considering the default cwd is configurable by the user?

Comment on lines +56 to +65
if (accumulated.includes('"type":"watching"')) {
break;
}

if (accumulated.includes('"type":"error"')) {
const match = accumulated.match(/"error"\s*:\s*"([^"]+)"/);
const msg = match?.[1] || 'Watch failed to establish';
reader.cancel().catch(() => {});
throw new Error(msg);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why string based matching, which can be flaky? I feel like for this whole loop we could be using one of our parsing utils considering it's just a readable stream?

Co-authored-by: Naresh <ghostwriternr@gmail.com>
github-actions[bot]

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

OpenCode Review

This PR adds comprehensive file watching functionality to the Sandbox SDK using Linux's native inotify system. The implementation is well-architected and follows established SDK patterns correctly.

Key Implementation Strengths

Architecture: Properly follows the three-layer pattern - WatchHandler in container, shared types, and integration points for future client work. The dependency injection integration is clean.

SSE Protocol: The new parseSSEFrames() utility in packages/shared/src/sse.ts is well-designed - it correctly handles partial events across chunk boundaries and follows the SSE spec properly.

WebSocket Streaming: The WebSocket adapter changes are particularly good. The stream cancellation handling (lines 471-509 in ws-adapter.ts) correctly pre-acquires the reader before async operations to avoid Bun's GC issues.

Error Handling: Comprehensive error codes added to shared types, proper validation in the handler, and good path sandboxing to /workspace.

Testing: Excellent test coverage - both unit tests for individual components and E2E tests for the complete workflow.

Technical Implementation Quality

inotify Integration: Smart use of inotifywait with proper argument building. The event coalescing (75ms window) and backpressure handling (1000 event limit) show thoughtful performance considerations.

Glob Pattern Handling: The limitation to basic glob syntax (*, **, ?) with rejection of complex patterns is good for predictable performance.

Stream Lifecycle: The setup synchronization waiting for "Watches established" before resolving is the right approach for deterministic behavior.

Resource Management: Proper cleanup in both stream cancellation and server shutdown paths.

Documentation Impact

This introduces new public API surface that will need documentation. The API design is intuitive and follows existing patterns.

Verdict: Looks excellent and ready to merge. The implementation is production-ready with proper error handling, resource management, and test coverage.

@ghostwriternr ghostwriternr merged commit 2af3c28 into main Mar 3, 2026
32 of 37 checks passed
@ghostwriternr ghostwriternr deleted the feature/file-watching branch March 3, 2026 10:26
scuffi pushed a commit that referenced this pull request Mar 3, 2026
* feat: add file watching capabilities with inotify support

* Create empty-poets-serve.md

* Potential fix for code scanning alert no. 42: Incomplete string escaping or encoding

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fixes for claude review

* update tests to verify regex format for default and custom excludes

* Fix error handling and type safety in WatchService and FileWatch classes. Update tests to validate new event parsing logic and ensure proper handling of inotifywait output.

* Refactor WatchService tests to validate combined regex patterns for default and custom excludes

* Added timeouts for event propagation

* Refactored and cleaned

* Small ws transport related fixes

* Timing changes help account for the additional buffering

* Fix WebSocket blocking issue for SSE streaming responses

Streaming responses (like file watch events) were blocking the WebSocket
message handler because handleStreamingResponse was awaited. This prevented
other messages from being processed while a stream was active.

Run streaming response handlers in the background with error logging,
allowing the message handler to return immediately and process subsequent
messages.

* Add debug logging and stream tracking for WebSocket streaming

Add detailed logging to trace streaming response handling and track active
streams to prevent potential garbage collection of Response objects.

* Acquire stream reader synchronously before async execution

The WebSocket message handler needs to capture the Response body reader
before any await points. When getReader() was called inside the async
handleStreamingResponse method, Bun's WebSocket handler would return
before the reader was acquired, potentially invalidating the Response
body stream.

By getting the reader synchronously in handleRequest before the promise
starts executing, we ensure the stream remains valid throughout the
async streaming loop.

* Wait for inotifywait watches to be established before signaling ready

The watching SSE event was sent immediately when the stream started,
before inotifywait finished setting up watches. This caused flaky tests
because file operations could occur before the watch was truly ready.

Now we read stderr and wait for the 'Watches established' message from
inotifywait before sending the watching event to clients.

* Add timeout to waitForWatchesEstablished to prevent hanging

If inotifywait fails to output 'Watches established' within 10 seconds,
the function will return and allow the stream to proceed. This prevents
indefinite hangs if inotifywait behaves unexpectedly.

* Wait for first message before returning WebSocket stream

For WebSocket streaming, errors were deferred until stream consumption.
This caused issues where watchStream() would return successfully even
when the server returned an error response.

Now requestStream() waits for the first message before returning:
- If it's a stream chunk, return the stream (success case)
- If it's an error response, throw immediately (error case)

This makes WebSocket streaming behavior match HTTP streaming, where
errors are thrown immediately rather than deferred.

* Address code review issues in file watching

Replace empty catch blocks with debug/warn logging throughout
WatchService and FileWatch to make failures visible. Fix
FileWatch.established() to reject on AbortSignal during
establishment, preventing indefinite hangs. Strengthen the SSE
event type guard to validate required fields per event type.
Add WATCH_STOP_ERROR code, integrate watch cleanup into server
shutdown, and rewrite changeset for end users.

* Fix file watch E2E test timeout race condition

The watchWithActions timeout started at stream creation but blocked
for ~6.5s inside the event handler (pre-action delay + file ops +
post-action delay), leaving insufficient time to read events in CI.
Reset the timeout after actions complete so the full window is
available for event collection.

* Fix exclude test timeout in file watch E2E

The combined wait time (~18s) approached the 30s Vitest timeout.
With excludes filtering most events, only 2-4 arrive, so the high
stopAfterEvents threshold was never reached and the test always
fell through to the full 12s reader timeout.

* Remove low-level watch API to simplify public interface

Remove watchStream(), stopWatch(), and listWatches() methods from
Sandbox class. The handle-based API via watch() is sufficient for
all use cases and prevents resource management confusion.

Keep the internal WatchService methods for container use but don't
expose them through the SDK public API.

* Harden file watch stream lifecycle

* Stabilize explicit watch stop e2e test

* Isolate file-watch e2e sessions per test

* Stabilize file-watch error and stop e2e cases

* Stabilize websocket e2e flake handling

Treat watch stop as idempotent when a watcher is already gone and make
OpenCode proxy health checks resilient to transient startup failures.
Relax the foreground timing threshold to reduce transport-related CI
jitter without masking blocking behavior.

* Treat ESRCH as success when stopping already-gone watch process

Handle the race where a watch process exits before stopWatch() is called.
When process.kill() throws ESRCH (no such process), clean up the watch
entry and return success instead of an error.

* Isolate file watch e2e sandbox

* Route watch E2E through SDK surface

* Use SDK watch bridge without new public API

* Remove unused watch stop endpoint and reduce stream logging verbosity

Remove /api/watch/stop endpoint and WatchClient.stop() method as watch
lifecycle is now managed through handle-based API. Clean up verbose
debug logging in WebSocket stream handler, keeping only completion
summary. Clear setup timeout in watch service to prevent leak.

* Scope stream cancellation to owning WebSocket connection

Track connectionId for each active stream and only allow cancellation from
the connection that initiated it. Prevent cross-connection cancel messages
and ensure onClose only cancels streams owned by the closing connection.

* Address review: parseSSEStream abort, watch readiness, include/exclude validation

- Fix parseSSEStream to register abort listener that calls reader.cancel(),
  unblocking idle streams instead of polling signal.aborted between reads
- Make watch() block until the watching event is received so callers can
  immediately perform actions that depend on the watcher being active
- Reject requests that specify both include and exclude with a clear
  validation error since inotifywait does not support both simultaneously
- Add timeout-race tests for WebSocket transport stream requests
- Simplify E2E watch helper: use parseSSEStream + AbortSignal.timeout,
  remove all hardcoded delays
- Remove test worker workaround that silently dropped exclude when
  include was present

* Simplify non-existent path error test

watch() now throws on establishment failure, so the test worker
returns a clear error response. The 3-path defensive logic is
no longer necessary.

* Simplify stop-watch test: remove manual watching detection

watch() now blocks until established, so the manual loop scanning
for the watching event is redundant. Read one chunk to confirm
the stream is live, then cancel.

* chore: retrigger CI

* Update .changeset/empty-poets-serve.md

Co-authored-by: Naresh <ghostwriternr@gmail.com>

* FIxed small review comments

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Naresh <ghostwriternr@gmail.com>
@github-actions github-actions Bot mentioned this pull request Mar 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants