Carve-out from #1395 (PR #1424). Part of epic #1394.
Problem
The Server-side resource-watch registry (packages/plugin-core/src/resource-handlers.ts) cleans up a watch via exactly two early paths:
- Explicit — the web client calls
UnwatchResource (last ref → subscription.close() → wire unsubscribe).
- Environment disconnect — the 30s reaper (
reapDeadWatches) drops any watch whose adapterManager.getConnection(environmentId) is now undefined.
There is a gap between them: if a web client vanishes (browser crash, laptop sleep, network drop, tab killed) while its environment stays connected, neither path fires:
- No
UnwatchResource is sent (client is gone).
getConnection() still returns a live connection, so the reaper skips the entry.
Result: the WatchEntry + PowerLine chokidar watcher stay alive watching a file nobody is viewing, until the environment eventually disconnects or the server restarts.
Why this was deferred (v0 tradeoff)
The blast radius is small and self-healing, so it was intentionally left for v1:
- Magnitude is one idle chokidar handle per
(environment, uri, recursive) a dead client had open (ref-count dedup means N dead clients of the same file = one watcher).
- Leaked
resource.changed events are harmless — live useResources hooks don't have the URI cached and no-op.
- Any environment stop/reconnect cycle reaps the leak via path 2.
The proper fix needs a liveness signal tying a watch to a specific web client, which the current global StreamEvents broadcast substrate doesn't carry (the server can't tell which client a watch belongs to, nor when one client's stream drops).
Proposed approaches (pick one during design)
- Lease / heartbeat (lightest):
WatchResource returns a lease the client renews on an interval (or fold renewal into the existing periodic activity); the reaper drops leases not renewed within ~2× the interval. Add a renewWatch RPC (or reuse an existing periodic call).
- Per-client watch ownership: bind each watch to the client's
StreamEvents connection and reap on that stream's close. Requires the watch registry to know about individual event-stream subscriptions (it currently doesn't).
Acceptance criteria
- A watch opened by a web client that then disconnects (without
UnwatchResource) is released within a bounded TTL, even while its environment stays connected.
- The wire
unsubscribe is sent so PowerLine releases its chokidar watcher.
- Existing explicit-unwatch and environment-disconnect reap paths keep working; ref-counting across multiple viewers of the same file is preserved.
- Unit coverage for the new reap path (lease expiry / client-stream close).
References
- Registry + reaper:
packages/plugin-core/src/resource-handlers.ts (reapDeadWatches, WATCH_REAPER_INTERVAL_MS, unwatchResource)
- Wire watch:
packages/powerline/src/ahp-handlers.ts (createResourceWatch / unsubscribe)
Carve-out from #1395 (PR #1424). Part of epic #1394.
Problem
The Server-side resource-watch registry (
packages/plugin-core/src/resource-handlers.ts) cleans up a watch via exactly two early paths:UnwatchResource(last ref →subscription.close()→ wireunsubscribe).reapDeadWatches) drops any watch whoseadapterManager.getConnection(environmentId)is nowundefined.There is a gap between them: if a web client vanishes (browser crash, laptop sleep, network drop, tab killed) while its environment stays connected, neither path fires:
UnwatchResourceis sent (client is gone).getConnection()still returns a live connection, so the reaper skips the entry.Result: the
WatchEntry+ PowerLine chokidar watcher stay alive watching a file nobody is viewing, until the environment eventually disconnects or the server restarts.Why this was deferred (v0 tradeoff)
The blast radius is small and self-healing, so it was intentionally left for v1:
(environment, uri, recursive)a dead client had open (ref-count dedup means N dead clients of the same file = one watcher).resource.changedevents are harmless — liveuseResourceshooks don't have the URI cached and no-op.The proper fix needs a liveness signal tying a watch to a specific web client, which the current global
StreamEventsbroadcast substrate doesn't carry (the server can't tell which client a watch belongs to, nor when one client's stream drops).Proposed approaches (pick one during design)
WatchResourcereturns a lease the client renews on an interval (or fold renewal into the existing periodic activity); the reaper drops leases not renewed within ~2× the interval. Add arenewWatchRPC (or reuse an existing periodic call).StreamEventsconnection and reap on that stream's close. Requires the watch registry to know about individual event-stream subscriptions (it currently doesn't).Acceptance criteria
UnwatchResource) is released within a bounded TTL, even while its environment stays connected.unsubscribeis sent so PowerLine releases its chokidar watcher.References
packages/plugin-core/src/resource-handlers.ts(reapDeadWatches,WATCH_REAPER_INTERVAL_MS,unwatchResource)packages/powerline/src/ahp-handlers.ts(createResourceWatch/unsubscribe)