feat: add file watching capabilities with inotify support#324
feat: add file watching capabilities with inotify support#324ghostwriternr merged 38 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 6b88d36 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
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
commit: |
🐳 Docker Images PublishedDefault: FROM cloudflare/sandbox:0.0.0-pr-324-ff19085With Python: FROM cloudflare/sandbox:0.0.0-pr-324-ff19085-pythonWith OpenCode: FROM cloudflare/sandbox:0.0.0-pr-324-ff19085-opencodeVersion: Use the 📦 Standalone BinaryFor 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-binaryExtract from Docker: docker run --rm cloudflare/sandbox:0.0.0-pr-324-ff19085 cat /container-server/sandbox > sandbox && chmod +x sandbox |
|
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? |
|
/bonk resolve conflict and review this PR |
There was a problem hiding this comment.
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-393—proc.kill()in thefinallyblock 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 incontinueStderrMonitoring. -
file-watch.ts:168—this.reader.cancel().catch(() => {})in thefinallyblock.
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. ThewatchWithActionshelper'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_FOUNDandWATCH_START_ERRORwith correct HTTP status mappings. - WebSocket transport improvements: Partial SSE event state tracking across chunks is a correct fix for chunked event parsing.
|
Done. Here's a summary of what I did: Merge Conflict ResolutionResolved the single conflict in Verification
Review SummaryThe 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 |
really keen to get this in this week or next week. sorry about the docs, someone got them merged ahead of time |
ghostwriternr
left a comment
There was a problem hiding this comment.
Biggest issue is perhaps confirming the API usage works as expected + some minor issues
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.
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.
ghostwriternr
left a comment
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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.
| const events: FileWatchSSEEvent[] = []; | ||
| let watchId: string | null = null; | ||
| let actionResult: T; | ||
| const reader = response.body.getReader(); |
There was a problem hiding this comment.
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.
| // 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); |
There was a problem hiding this comment.
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.
| 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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Any time we are using magic timeouts, that's a big code/design smell imo.
There was a problem hiding this comment.
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 }, |
There was a problem hiding this comment.
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.
| 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"'); | ||
| } |
There was a problem hiding this comment.
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.
| // 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); | ||
| } |
There was a problem hiding this comment.
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
watch() now throws on establishment failure, so the test worker returns a clear error response. The 3-path defensive logic is no longer necessary.
|
/bonk check if I missed anything based on Naresh's last review |
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.
ghostwriternr
left a comment
There was a problem hiding this comment.
very minor comments, looks good overall
| import type { WatchService } from '../services/watch-service'; | ||
| import { BaseHandler } from './base-handler'; | ||
|
|
||
| const WORKSPACE_ROOT = '/workspace'; |
There was a problem hiding this comment.
Should maybe use CONFIG.DEFAULT_CWD, considering the default cwd is configurable by the user?
| 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); | ||
| } |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
* 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>
User problems this solves
This feature is designed for concrete workflows, not just API surface:
API
Key behaviors
watch()blocks until the filesystem watcher is established. No need to scan for awatchingevent before performing actions.ReadableStream<Uint8Array>consumed viaparseSSEStream. This is the only viable approach given the RPC layer (AsyncIterable cannot cross the boundary).parseSSEStreamregisters an abort listener that callsreader.cancel(), which propagates server-side cleanup. No explicit stop endpoint needed.include/excludeare mutually exclusive: Validated at the container layer sinceinotifywaitdoes not support both simultaneously.Event flow
Hardening included in this PR
watching).watch()throws if the watcher fails to establish.path,recursive,include,exclude. Rejects bothincludeandexcludetogether./workspaceboundary.reader.cancel()orAbortSignalabort propagates to kill theinotifywaitprocess server-side. No orphaned watchers.onCloseonly cancels streams belonging to the closing WebSocket connection, not all active streams.*,**,?, path separators). Unsupported complex tokens are rejected via validation.Reviewer notes
1) SDK watch client + readiness
packages/sandbox/src/clients/watch-client.tspackages/sandbox/src/sandbox.tswaitForReadiness()reads chunks untilwatchingevent, returns wrapper stream that replays buffered data2) SSE parser abort handling
packages/sandbox/src/sse-parser.tsreader.cancel()to unblock idle streams; cleanup removes listener3) Container watch routing/validation
packages/sandbox-container/src/handlers/watch-handler.tspackages/sandbox-container/src/routes/setup.tsinclude/excludemutual exclusivity, path normalization4) Watch process + stream robustness
packages/sandbox-container/src/services/watch-service.ts5) WebSocket adapter stream ownership
packages/sandbox-container/src/handlers/ws-adapter.tsactiveStreamstracking, scoped cancellation on close6) End-to-end coverage
tests/e2e/file-watch-workflow.test.tstests/e2e/test-worker/index.tsparseSSEStream+AbortSignal.timeoutwith no hardcoded delays.Key design decisions
parseSSEStreamis the only approach that works across the RPC boundary. NoonEventcallback orwatcher.stop().watch()blocks until established so callers know the watcher is active before performing filesystem actions.AbortSignalor canceling the stream reader propagates cleanup. No/api/watch/stopneeded.