Skip to content

Fix WebSocket subscription catch-up contract#239

Merged
kjgbot merged 1 commit into
mainfrom
fix/ws-subscription-contract
Jun 5, 2026
Merged

Fix WebSocket subscription catch-up contract#239
kjgbot merged 1 commit into
mainfrom
fix/ws-subscription-contract

Conversation

@kjgbot

@kjgbot kjgbot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

Fixes #238 and the upstream contract gap identified from AgentWorkforce/pear#82 / AgentWorkforce/pear#82 (comment).

This changes the WebSocket event subscription contract so updated SDK clients no longer receive historical workspace events as fresh live events by default:

  • /fs/ws now accepts from=now, exclusive cursor=<eventId>, and repeated path=<filter> query params.
  • Server catch-up honors from=now by sending no historical backfill, honors cursor by returning only events after that cursor, and keeps the catch-up bounded to 100 events.
  • Server-side path filters are applied to both catch-up and live fanout before writing frames to the client.
  • Legacy no-query WebSocket clients keep the existing recent-event catch-up behavior for backward compatibility.
  • WebSocket frames now include eventId, origin, provider, and correlationId so consumers can update cursors and suppress self-echoes reliably.
  • TypeScript SDK WebSocket transports (RelayFileSync, RelayFileClient.connectWebSocket, subscribe, open, onWrite) default to from=now; cursor and path filters are surfaced in the public options.

Pear SDK bump note

Pear currently pins @relayfile/sdk@0.7.23. After this PR is released, Pear needs to bump to the first published @relayfile/sdk version that includes this PR, then use the SDK's default from=now WebSocket behavior plus pathScope / paths for server-side scoped subscriptions.

Validation

  • go test ./internal/httpapi -run 'TestFileEventsWebSocket'
  • go test ./internal/httpapi ./internal/relayfile
  • go test ./...
  • npm run build --workspace=packages/core
  • npm run build --workspace=packages/sdk/typescript
  • npm run typecheck --workspace=packages/sdk/typescript
  • npm run test --workspace=packages/sdk/typescript -- --run src/sync.test.ts src/client.test.ts
  • npm run test --workspace=packages/sdk/typescript
  • scripts/check-contract-surface.sh

Note: the first SDK build/typecheck attempt failed in the fresh worktree because @relayfile/core had not been built yet. After npm run build --workspace=packages/core, SDK build/typecheck passed.


CodeAnt-AI Description

Fix WebSocket catch-up and filtering for file event subscriptions

What Changed

  • New WebSocket subscriptions now start with live-only events by default, or can resume from an exclusive event cursor without repeating the cursor event.
  • Path filters now apply to both catch-up and live events, so subscriptions only deliver matching files.
  • WebSocket messages now include event details needed to track progress and identify the source of each change.
  • The SDK now forwards from=now, cursor, and repeated path filters when opening or reconnecting WebSocket subscriptions.

Impact

✅ Fewer duplicate events after reconnect
✅ Cleaner live-only subscriptions
✅ More precise file change feeds

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@codeant-ai

codeant-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements configurable WebSocket event subscriptions for RelayFile, enabling clients to control catch-up behavior via from=now (live-only) or exclusive cursor (resume), and filter events by path patterns server-side. The backend persists event cursors and filters during replay and live delivery; the SDK exposes these controls via options and automatically manages subscription lifecycle with path-scope restarts.

Changes

WebSocket Subscriptions with Cursor, from=now, and Path Filtering

Layer / File(s) Summary
Backend event cursor pagination
internal/relayfile/store.go
GetEventsAfterCursor() parses event ID ordinals and uses binary search to fetch paginated events after a cursor; ordinal helper validates evt_<n> format.
Backend WebSocket handler and filtering
internal/httpapi/websocket.go
fileEventMessage expands with event ID, origin, correlation ID, and revision metadata; handler parses from, cursor, and path subscription options; new helpers normalize path filters (absolute paths, de-dupe), match events against * and ** patterns, and select catch-up strategy; both catch-up replay and live delivery skip non-matching events.
Backend WebSocket subscription tests
internal/httpapi/server_test.go
New tests validate that from=now suppresses backfill on subscribe/reconnect, cursor-based catch-up is exclusive (starts after the cursor), path filters suppress non-matching events, and writeback materialization carries origin=agent_write; existing file.created origin assertion is added.
OpenAPI specification
openapi/relayfile-v1.openapi.yaml
Endpoint description and query parameters updated to document from=now, exclusive cursor, and repeatable path filters; legacy behavior (no params) retains historical catch-up.
SDK type contracts and options
packages/sdk/typescript/src/types.ts, packages/sdk/typescript/src/sync.ts, packages/sdk/typescript/src/index.ts
RelayFileSyncStart type added ("now" | "legacy"); SubscribeOptions and ChangeStreamConnectionOptions gain optional from and cursor; RelayFileSyncOptions gains optional from and paths; new type re-exported from SDK index.
SDK sync implementation with path filtering
packages/sdk/typescript/src/sync.ts
Path filter helpers normalize filters (trim, absolute, de-dupe) and match events with * segment and ** prefix wildcards; RelayFileSync stores from and normalized paths; WebSocket URL includes cursor or from=now, plus repeated path params; polling and WebSocket delivery route through path filter check.
SDK client subscription lifecycle and stream manager
packages/sdk/typescript/src/client.ts
ConnectWebSocketOptions gains from, cursor, and paths; StreamStartOptions introduced for start parameters; stream manager caching keyed by start options; activePathFilterKey tracks current server-side filters; subscribe() triggers path-scope restart via restartIfPathScopeChanged(); ensureStarted() computes server-side filters and starts sync with options; maybeStop() resets filter key; getStreamManager() incorporates start options into cache key.
SDK WebSocket and subscription tests
packages/sdk/typescript/src/sync.test.ts, packages/sdk/typescript/src/client.test.ts, packages/sdk/typescript/src/onWrite.test.ts
Tests verify WebSocket URL includes from=now by default, cursor and path filters are properly encoded on connect/reconnect, and subscription multiplexing includes path parameters; fake timers validate reconnect cursor update while retaining path scope.
Documentation
packages/sdk/typescript/CHANGELOG.md
Changelog notes default from=now subscriptions, exclusive cursor resume support, and path filter forwarding for scoped catch-up and live delivery.

Sequence Diagram(s)

sequenceDiagram
  participant Client as SDK Client
  participant StreamMgr as StreamManager
  participant Sync as RelayFileSync
  participant WS as WebSocket
  participant Server as Backend Server
  participant Store as Store

  Client->>StreamMgr: subscribe(pathScope)
  StreamMgr->>StreamMgr: restartIfPathScopeChanged()
  alt path scope differs
    StreamMgr->>Sync: stop()
  end
  StreamMgr->>StreamMgr: ensureStarted()
  StreamMgr->>StreamMgr: compute serverPathFilters
  StreamMgr->>Sync: start(from, cursor, paths)
  Sync->>Sync: normalize paths
  Sync->>WS: connect with URL params<br/>(cursor or from=now, path)
  WS->>Server: GET /fs/ws?cursor=evt_5&path=/a&path=/b
  Server->>Server: parseWebSocketSubscriptionOptions
  Server->>Store: GetEventsAfterCursor(cursor)
  Store-->>Server: recent events
  Server->>Server: filter by paths
  Server-->>WS: catch-up events (matching only)
  WS-->>Sync: message{eventId, path, origin}
  Sync->>Sync: advance cursor from eventId
  Sync->>Sync: emit if path matches
  Sync-->>Client: FilesystemEvent
  Server->>Server: subscribe to live
  loop live delivery
    Server->>Server: filter by paths
    Server-->>WS: live events (matching only)
    WS-->>Sync: message{eventId, path}
    Sync->>Sync: emit if path matches
    Sync-->>Client: FilesystemEvent
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A cursor hops through event streams so fine,
From "now" we spring, no backfill to rewind,
With glob-scoped paths the server fans just right,
Live events arrive, filtered to the light,
The chase begins—reconnect without the fright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix WebSocket subscription catch-up contract' accurately and concisely describes the main change—it addresses the core fix of the WebSocket subscription contract behavior.
Description check ✅ Passed The PR description is clearly related to the changeset, detailing the WebSocket contract updates, new query parameters, and SDK changes that align with the file modifications.
Linked Issues check ✅ Passed The PR comprehensively addresses all requirements from #238: default from=now behavior, exclusive cursor support, path-scoped catch-up, backward compatibility, and expanded WebSocket frames with metadata fields.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the stated objectives: WebSocket contract fixes, server-side path filtering, SDK defaults, documentation updates, and comprehensive test coverage for the new behaviors.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ws-subscription-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Relayfile Eval Review

Run: .relayfile/evals/runs/2026-06-05T14-50-42-888Z-HEAD-provider
Mode: provider
Git SHA: faf7ef0

Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0

Human Review Cases

No reviewable human-review cases captured Relayfile output.

@kjgbot kjgbot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review: WS subscription contract (relayfile#238 / pear#82 Track C) — scope + acceptance + backward compat

Scope conformance: ✅ Every change maps to the #238 contract (server: from=now/cursor/path params, bounded cursor catch-up, filtered fanout, enriched frames; SDK: option surface + cursor tracking + scoped restart; OpenAPI + changelog). No creep.

Contract verification at head 3efab05:

  1. from=now skips backfill on subscribe and reconnect: ✅ server honors it in webSocketCatchUpEvents; test proves no backfill before pong on both initial connect and reconnect, and live events still flow.
  2. Exclusive cursor resume, capped at 100: ✅ GetEventsAfterCursor binary-searches the monotonic evt_<n> ordinal, starts strictly after the cursor, caps at 100. SDK updates this.cursor from every framed eventId, so reconnects resume rather than replay.
  3. Server-side path filters before fanout: ✅ applied to both catch-up and the live loop before frame writes — actual server-side constraint, not client-side discard. Verified the Go matcher and the SDK's matchChangeSegments have identical semantics (trailing ** + single-segment *), so no server/client filter asymmetry.
  4. Frames carry eventId/origin/provider/correlationId: ✅ additive JSON fields. The TestFileEventsWebSocketWritebackMaterializationCarriesAgentWriteOrigin test closes the end-to-end dependency flagged in pear#101's review — Pear's agent_write self-echo filter is now backed by a server guarantee.
  5. Backward compatibility: ✅ legacy no-query clients get the exact pre-change GetRecentEvents(workspaceID, 100) path; new frame fields are additive (old SDK 0.7.23 ignores them); subscribe-before-catch-up ordering + dedup is preserved, so no missed-event window at connect. The SDK's from=now default only takes effect on upgrade, which is the intended contract and is changelogged.

Tests: ran locally at head — go test ./... all pass; SDK vitest 185/185. CI fully green (Go Build/Test, SDK Typecheck, E2E, contract surface, provider evals). My local SDK tsc run hit TS2307 on @relayfile/core, but that's a hand-linked node_modules artifact of my review sandbox — green CI SDK Typecheck is authoritative.

Unbounded-dataflow check: catch-up bounded (100), fanout narrowed by filters, no new polling or recursive scans. (ws.Events grows unbounded in memory, but that's pre-existing and out of scope here.)

Findings (non-blocking, mostly contract-documentation follow-ups)

  1. Silent gap on unknown/expired cursorGetEventsAfterCursor returns [] when the cursor isn't found (e.g. server restart resets the in-memory event log). The client resumes live believing it's caught up, with no signal. There's also a generation-ambiguity hazard: eventCounter restarts after a server restart, so an old-generation evt_N can exactly match an unrelated new-generation event. Fine for Pear's no-replay use case; integrity-sensitive consumers need a cursor-reset/gap signal frame eventually. Suggest tracking as a follow-up issue.
  2. Silent truncation at the 100-event cap — >100 events past the cursor → client gets the first 100 plus live-from-subscribe-time; the middle window is dropped with no "truncated" indicator and no client paging. Bounded-by-design per #82, but the contract should say so (or emit a gap marker).
  3. SDK restart-on-path-scope-change loses the live cursorrestartIfPathScopeChanged rebuilds RelayFileSync with the original startOptions.cursor, not the last-seen cursor the stopped sync had accumulated; with the from=now default, events during the restart window are lost. Suggest threading the old sync's current cursor into the replacement.
  4. Doc nit: path filters support trailing ** and single-segment * only (mid-pattern ** compares literally, identically on both sides). Worth one line in the OpenAPI description so consumers don't pass mid-globs expecting full glob semantics.

Verdict

APPROVE — contract gap closed with bidirectional test coverage and clean backward compat. Findings 1–3 are good candidates for a follow-up issue on the subscription contract; none block this PR. After release, Pear's Track C consumes this via the SDK bump as planned.

@kjgbot kjgbot merged commit 3d9f08d into main Jun 5, 2026
8 of 9 checks passed
@kjgbot kjgbot deleted the fix/ws-subscription-contract branch June 5, 2026 14:57
@agent-relay-code

Copy link
Copy Markdown
Contributor

⚠️ pr-reviewer push failed (exit 128) — fixes were not applied to the PR. The notes below are advisory and were not pushed.

Implemented a PR-specific fix in the TypeScript SDK:

  • Preserved the current websocket event cursor when RelayFileChangeStreamManager restarts RelayFileSync after path-scope subscription changes.
  • Added regression coverage in client.test.ts verifying the restarted socket uses cursor=evt_linear_1 instead of restarting with from=now.
  • Made one existing sync recovery test deterministic by waiting for recovery instead of sleeping a fixed 300 ms.

Validated stale bot findings locally:

  • SDK tsconfig already excludes tests.
  • BulkWriteResponse already includes correlationId.

Verification run:

  • npm run build passed.
  • npm run typecheck passed.
  • Full SDK npm test passed: 185 tests.
  • scripts/check-contract-surface.sh passed.
  • Go tests could not be run because go is not installed in this sandbox.

Comment on lines +66 to +82
function normalizeWebSocketPathFilters(paths: string[] | undefined): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of paths ?? []) {
const path = typeof value === "string" ? value.trim() : "";
if (!path) {
continue;
}
const absolute = path.startsWith("/") ? path : `/${path}`;
if (seen.has(absolute)) {
continue;
}
seen.add(absolute);
normalized.push(absolute);
}
return normalized;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Path filters are accepted without validating glob grammar, but matching logic only supports ** as a trailing segment. Invalid filters like /a/**/b are silently accepted and then never match, causing all events to be dropped unexpectedly. Reject unsupported patterns during normalization (same behavior as onWrite pattern validation) to avoid silent non-delivery. [incorrect condition logic]

Severity Level: Major ⚠️
- ❌ RelayFileSync subscriptions can silently drop all scoped events.
- ⚠️ WebSocket path filters behave inconsistently with onWrite patterns.
- ⚠️ Difficult debugging when invalid filters yield empty streams.
Steps of Reproduction ✅
1. A consumer constructs `RelayFileSync` directly with an invalid glob-style path filter
such as `paths: ["/a/**/b"]` in `RelayFileSyncOptions` (type defined at
`packages/sdk/typescript/src/sync.ts:27-44`), then calls `sync.start()`.

2. In the `RelayFileSync` constructor (`packages/sdk/typescript/src/sync.ts:73-90`), the
`paths` option is normalized via `normalizeWebSocketPathFilters` at lines 66-82. This
helper only trims whitespace, enforces a leading `/`, and deduplicates values; it does not
validate glob grammar or restrict `**` to the trailing segment, so `"/a/**/b"` is silently
accepted and stored in `this.paths`.

3. On connect, `openWebSocketWithToken` at `packages/sdk/typescript/src/sync.ts:249-259`
appends each filter from `this.paths` as `path` query parameters, so the server receives
`path=/a/**/b`. The server parses these in `parseWebSocketSubscriptionOptions`
(`internal/httpapi/websocket.go:122-128`) using its own `normalizeWebSocketPathFilters`,
then filters events via `webSocketEventMatchesPaths` and `webSocketPathMatches` at
`internal/httpapi/websocket.go:161-199`. `webSocketPathMatches` only treats `**` as a
wildcard when it is the *final* segment; for a pattern like `/a/**/b` and an event path
like `/a/x/b`, the match fails and the server never writes that event to the WebSocket.

4. Even if an event with a non-matching path slipped through, the client-side
`emitFilesystemEvent` at `packages/sdk/typescript/src/sync.ts:33-38` re-applies filters
using `pathMatchesAnyFilter`, which calls `matchPathSegments` at lines 101-109. This uses
the same semantics as the server (only trailing `**` is special), so `/a/**/b` never
matches `/a/x/b` here either. In contrast, `onWrite` patterns are validated up-front in
`normalizePattern` at `packages/sdk/typescript/src/onWrite.ts:74-88`, which throws when
`**` appears anywhere but the last segment. Because `RelayFileSync` path filters lack
equivalent validation, callers who pass patterns like `/a/**/b` will see their
subscription receive no events from paths they expect to match, with no error or warning.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/sdk/typescript/src/sync.ts
**Line:** 66:82
**Comment:**
	*Incorrect Condition Logic: Path filters are accepted without validating glob grammar, but matching logic only supports `**` as a trailing segment. Invalid filters like `/a/**/b` are silently accepted and then never match, causing all events to be dropped unexpectedly. Reject unsupported patterns during normalization (same behavior as `onWrite` pattern validation) to avoid silent non-delivery.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

baseUrl: this.baseUrl,
token: this.token,
from: this.startOptions.from,
cursor: this.startOptions.cursor,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The stream restart path re-creates RelayFileSync using only the original start cursor, so any cursor progress made by the running sync is discarded. When path-scope changes trigger a restart, the new socket starts from stale state (from=now by default) and can silently miss events emitted during the restart window. Persist and reuse the latest delivered cursor when rebuilding the sync instance. [stale reference]

Severity Level: Critical 🚨
- ❌ Subscribe-based change streams can silently miss workspace events.
- ❌ Proactive runtimes may skip changes during path-scope updates.
- ⚠️ Downstream consumers cannot reliably advance event cursors.
Steps of Reproduction ✅
1. In a proactive runtime, start a change stream via `RelayFileClient.subscribe()` at
`packages/sdk/typescript/src/client.ts:1453-1470` without passing `from` or `cursor`, so
`SubscribeOptions.from`/`cursor` are undefined and `getStreamManager()` at
`client.ts:766-787` constructs a `RelayFileChangeStreamManager` with
`startOptions.from`/`cursor` unset.

2. The manager's `ensureStarted()` at `client.ts:664-305` creates a `RelayFileSync` with
`from: this.startOptions.from` and `cursor: this.startOptions.cursor` (line 675), so
inside `RelayFileSync`'s constructor at `packages/sdk/typescript/src/sync.ts:83-100` the
`from` defaults to `"now"` and the internal `cursor` is initially undefined.

3. As filesystem events arrive over the WebSocket, `RelayFileSync.handleSocketMessage()`
at `sync.ts:251-41` parses each event, updates its internal `this.cursor = parsed.eventId`
(line 37), and emits events to the manager, so forward progress is tracked only inside the
running `RelayFileSync` instance.

4. Later, update the subscription path scopes (for example, by adding another subscription
with a different `pathScope` in `RelayFileChangeStreamManager.addSubscription()` at
`client.ts:206-219`), which calls `restartIfPathScopeChanged()` at `client.ts:307-321`,
stops the current `RelayFileSync`, and immediately calls `ensureStarted()` again. The new
`RelayFileSync` is constructed with `cursor: this.startOptions.cursor` (still the
original, often `undefined`) and `from` defaulting to `"now"`, so
`openWebSocketWithToken()` at `sync.ts:259-16` sends either the stale initial cursor or
`from=now` on the new `/fs/ws` URL. Any events appended after the old stream stopped and
before the new socket's `from=now` snapshot are not backfilled (per the server contract
described in the PR) and are never delivered to subscribers.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/sdk/typescript/src/client.ts
**Line:** 675:675
**Comment:**
	*Stale Reference: The stream restart path re-creates `RelayFileSync` using only the original start cursor, so any cursor progress made by the running sync is discarded. When path-scope changes trigger a restart, the new socket starts from stale state (`from=now` by default) and can silently miss events emitted during the restart window. Persist and reuse the latest delivered cursor when rebuilding the sync instance.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +715 to +719
const sync = this.sync;
this.sync = undefined;
void sync.stop();
if (this.openHandleCount > 0 || this.subscriptions.size > 0) {
this.ensureStarted();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This restart logic stops the current sync asynchronously and immediately starts a new one without waiting for shutdown to finish. That creates an overlap window where the old sync can still emit events (especially in polling mode) while the new sync is already active, causing duplicate/out-of-scope delivery races. Serialize restart by awaiting stop completion (or gate old emissions) before starting the replacement. [race condition]

Severity Level: Major ⚠️
- ⚠️ Duplicate change events during path-scope reconfiguration windows.
- ⚠️ Subscribers may see events outside updated path scopes.
- ⚠️ Proactive runtimes may mis-dedupe overlapping event deliveries.
Steps of Reproduction ✅
1. Configure `RelayFileSync` to prefer polling (e.g., by running without a `baseUrl` or
with `preferPolling: true`), so `RelayFileSync.start()` at
`packages/sdk/typescript/src/sync.ts:147-159` calls `startPolling()`, which launches
`pollLoop()` at `sync.ts:166-235` and begins periodically fetching events via
`client.getEvents()`.

2. Attach one or more subscribe handlers through `RelayFileClient.subscribe()` at
`packages/sdk/typescript/src/client.ts:1453-1470`, which uses `getStreamManager()` at
`client.ts:766-787` to obtain a shared `RelayFileChangeStreamManager` and then
`addSubscription()` at `client.ts:206-219` to register `RelayFileChangeSubscription`
instances that consume `RelayFileSync`'s `event` notifications.

3. While the stream is in polling mode and delivering events, change the subscription path
filters (for example, by adding or removing a subscription with a different `pathScope`),
which causes `addSubscription()`/`unsubscribe()` to call `restartIfPathScopeChanged()` at
`client.ts:307-321`. That method captures the current `sync`, sets `this.sync =
undefined`, then calls `void sync.stop();` followed immediately by `this.ensureStarted()`
if there are still open handles or subscriptions.

4. Because `sync.stop()` is asynchronous, its `pollLoop()` continues the current iteration
until it next checks `this.stopped` in its `while (!this.stopped)` condition at
`sync.ts:166-235`, processing any already-fetched events and calling
`emitFilesystemEvent()``emit("event", ...)` even after the manager has created and
started a new `RelayFileSync` instance. During this overlap window, subscribers attached
via `RelayFileChangeSubscription.push()` at `client.ts:95-176` can receive events from
both the old and new syncs, leading to duplicate deliveries or events filtered using
outdated path-scopes.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/sdk/typescript/src/client.ts
**Line:** 715:719
**Comment:**
	*Race Condition: This restart logic stops the current sync asynchronously and immediately starts a new one without waiting for shutdown to finish. That creates an overlap window where the old sync can still emit events (especially in polling mode) while the new sync is already active, causing duplicate/out-of-scope delivery races. Serialize restart by awaiting stop completion (or gate old emissions) before starting the replacement.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai

codeant-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Reviewed PR #239 and made one focused fix.

Changed:

  • Path-filtered WebSocket catch-up now applies the 100-event limit after filtering, so reconnects do not miss matching events behind a burst of irrelevant events.
  • Added a regression test covering cursor catch-up with 101 non-matching events before the next matching path event.

Validated:

  • go test ./internal/httpapi ./internal/relayfile
  • scripts/check-contract-surface.sh
  • npm run typecheck
  • npm test -- src/sync.test.ts src/client.test.ts src/onWrite.test.ts

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

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket subscriptions need cursor, from=now, and path-scoped catch-up

1 participant