fix(sse): add periodic heartbeats to web factories and agent streams - #277
fix(sse): add periodic heartbeats to web factories and agent streams#277jaredglaser wants to merge 1 commit into
Conversation
The web SSE factories only sent the initial ': ok' flush comment, and on the agent only the logs route pinged. Streams with no traffic (idle broadcast services, hosts with no running containers, silent iostat) were killed by reverse proxies and Bun's idleTimeout. Both factories now run a 25 s setInterval comment ping, cleared in teardown and guarded by the closed flag; a ping that hits a dead consumer triggers teardown. The agent gets a shared startSseHeartbeat helper in sse-utils, wired into the stats, zfs, and containers-events streams with the interval injectable for tests. Worker collectors and browser EventSource both ignore comment frames, so no consumer changes are needed. Closes #233 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 46 minutes and 2 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (12)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Triage verdict: Close (obsolete) (confidence: high)Closing this as overtaken by main, with one piece worth re-filing. src/lib/sse/create-sse-stream.ts is now the single owner of the SSE wire protocol and already implements the heartbeat (DEFAULT_HEARTBEAT_MS = 5000 at :13, setInterval at :89, cleared in teardown at :57-63), and both factories this PR edits were rewritten to delegate to it. main also carries the equivalent test at src/lib/sse/tests/create-stats-sse-handler.test.ts:172, and agent/src/routes/containers-events.ts:126-141 has its own 5s heartbeat. createStatsSseHandler gained a second channel argument on main, so the signature here no longer fits either. All 15 conflict hunks are in those four already-solved files. On the residual: agent/src/routes/stats.ts genuinely has no heartbeat and should get one, since a host with zero running containers produces a silent stream. agent/src/routes/zfs.ts does not need one though, because it pipes zpool iostat -v 1 and the loop at :44-64 enqueues every non-empty line, so that stream emits every second for the life of the process and cannot idle. One correction for the follow-up: 25s is too slow. Bun's default HTTP idleTimeout is 10s, which main notes explicitly at containers-events.ts:127-129 and is why it chose 5s, and the agent only sets idleTimeout: 0 inside the websocket block of Bun.serve. Please also use the same ':\n\n' comment frame both existing implementations emit rather than ': ping\n\n', so there is one spelling on the wire. Suggest retitling issue #233 to 'agent /stats/stream has no SSE heartbeat' rather than closing it, since its cited file and line locations no longer exist on main. Specific items to fix
Effort if pursued: n/a (recommend close). Follow-up for agent/src/routes/stats.ts is about 15 lines plus a test, under an hour. Obsolescence: fully for the web half and the containers-events half; the zfs half is unnecessary. Proof: origin/main:src/lib/sse/create-sse-stream.ts:13,57-63,89; origin/main:src/lib/sse/create-broadcast-sse-handler.ts:1 and create-stats-sse-handler.ts:1,12,16 both delegate to createSseStream; origin/main:src/lib/sse/tests/create-stats-sse-handler.test.ts:172; origin/main:agent/src/routes/containers-events.ts:126-141; origin/main:agent/src/routes/zfs.ts:25-28,44-64 (zpool iostat -v 1, enqueues every line). Sources checked
Produced by a three-pass review (independent reviewer, adversarial re-reviewer, judge). Where the two passes disagreed, the disagreement was resolved by rechecking the code on Generated by Claude Code |
…equired heartbeat (#368) ## Summary Two of the agent's four SSE routes had no heartbeat. Rather than adding one to each, this ports the web app's `createSseStream` seam into the agent package and converts all four routes onto it, so the heartbeat is armed by stream construction and there is no call left to forget. Replaces #277, which was closed as overtaken: `src/lib/sse/create-sse-stream.ts` already owns the heartbeat on the web side, and both web factories delegate to it. Only the agent half was genuinely outstanding. ## The seam `agent/src/lib/sse-stream.ts` is a port of `src/lib/sse/create-sse-stream.ts`. `createSseStream(request, { onStart, heartbeatMs = 5000 })` returns the finished `Response`. The heartbeat is armed unconditionally inside `start()` right after the `': ok\n\n'` flush. `onStart(emit, signal)` receives only an `SseEmitter` (`data` / `event` / `raw` / `close`) and may return a cleanup that runs exactly once at teardown. One `teardown` clears the interval, runs cleanup and closes the controller; every write goes through the deferred-build `write()` which tears down on failure; abort is registered before `onStart` with an `aborted` recheck after it resolves. It is a deliberate copy, not an import. The agent is not a workspace member and web/worker take only types from it via `@homelab-manager/agent/*` with no runtime dependency, so it cannot import web code. The duplication is what the package split forces. The web seam's test file was ported verbatim (import path only) to `agent/src/lib/__tests__/sse-stream.test.ts`; all 18 pass unmodified, which is the evidence that the port is faithful. ## Routes converted - `containers-events.ts` and `logs.ts` lose their hand-rolled `ReadableStream`, `closed` flags, abort handlers and inline heartbeat blocks. - `stats.ts` and `zfs.ts` gain a heartbeat as a consequence of the conversion. `stats.ts` previously had none at all, so a host with zero running containers produced a silent stream. Removed in total: 2 inline heartbeat implementations, 4 hand-rolled `ReadableStream` teardown paths and 2 local `sendSSE` helpers. `agent/src/lib/sse-utils.ts` was deleted; no consumer remained. ### Why `zfs.ts` needed one after all The earlier reasoning was that `zpool iostat -v 1` emits every second so the stream cannot idle. That is true of the healthy case and insufficient. The route has no timeout, no tick of its own and no output of its own: every frame originates from a line read off zpool's stdout, and `reader.read()` blocks identically whether zpool is quiet or wedged. A degraded pool with a hung disk stalls output, Bun's 10s default `idleTimeout` then drops the socket, and that is exactly when the dashboard should stay connected. ### Lifecycle subtlety worth reviewing `stats.ts` and `zfs.ts` own long-running loops. If `onStart` awaited the loop, the returned cleanup would never be registered and a mid-loop teardown would never kill the subprocess or stop the poll. Both start the loop with `void` and return cleanup synchronously; the loop observes a `stopped` flag the cleanup flips. ## Constraints 5000ms, not 25000. Bun's default HTTP `idleTimeout` is 10s, which `containers-events.ts` already documented and is why the existing routes chose 5s. Frames use the bare `':\n\n'` comment, matching both existing implementations. ## Consumers verified, not assumed All three streaming worker collectors go through `connectAgentSseStream`, whose `readFrames` does `const dataLine = lines.find((line) => line.startsWith('data: ')); if (!dataLine) continue;` so comment frames are skipped before any JSON parse. `src/routes/api/docker-logs.$containerId.ts` forwards agent chunks verbatim through `emit.raw`, so pings pass through and it needs no second heartbeat. Browser `EventSource` ignores `:` comments per spec. ## Testing - `bun run typecheck:all` clean. - Agent tests 324 to 337, zero failures. Each converted route has an idle-heartbeat test asserting both the 5000ms cadence and a real `':\n\n'` frame on the wire, driven by a `globalThis.setInterval` spy per CLAUDE.md rule 7. - `bun run test:coverage:agent` passes; `sse-stream.ts`, `containers-events.ts`, `logs.ts` and `zfs.ts` all at 100% lines. - Web `src/lib/sse/__tests__/` and `src/worker/collectors/__tests__/`: 134 pass. No web source files changed. `containers-events` is one test lower because its two inline heartbeat tests collapsed into one idle-heartbeat test; the clears-on-abort case is now covered at the seam. ## Note for the ESLint PR This PR removes 22 of the 23 findings the proposed SSE lint rules produce. If this merges first, those five `no-restricted-syntax` selectors can land as `error` with one remaining fix in `src/routes/api/settings.ts:17`, rather than as `warn`. --- _Generated by [Claude Code](https://claude.ai/code/session_01E72xKQhMceYgcw4WrtjZau)_ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Improvements** - Standardized Server-Sent Events (SSE) streaming across container events, logs, statistics, and ZFS with consistent `: ok` startup and regular 5s heartbeat comment frames. - Improved client disconnect and mid-stream abort handling to reliably stop ongoing background streaming work. - Strengthened SSE parsing and event framing to emit the same JSON payload shapes while improving “not found” and error signaling. - **Documentation** - Updated streaming documentation to describe the SSE response/header format and the required route teardown/cleanup contract. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com>
|
Closing as superseded by #368. The heartbeat this PR adds now lives on main inside the shared SSE stream factories, on both sides of the web/agent split:
Both carry the injectable Nothing is lost by closing. If a gap turns up, the factory is the place to fix it once rather than per route. Generated by Claude Code |
|
Amending my previous comment on one point. I wrote "nothing is lost by closing" as a bare assertion. The triage verdict above found a real residual, so it deserved evidence rather than a claim. The residual it named was
The triage's other correction also holds on main: it objected that this PR's 25s interval exceeds Bun's 10s default HTTP So the obsolescence is now complete rather than partial, including the half the triage wanted re-filed. Closing, and closing #233 with it. Generated by Claude Code |
Summary
createStatsSseHandler,createBroadcastSseHandler) now run a 25 ssetIntervalcomment ping (: ping), guarded by theclosedflag and cleared inteardown. A ping that hits a dead consumer (enqueue throws) triggers full teardown, so abandoned streams unsubscribe instead of lingering.loadSubscriberejects) now route throughteardownso the interval cannot leak.startSseHeartbeathelper toagent/src/lib/sse-utils.tsand wired it into the agent stats, zfs, and containers-events streams (cleared on abort and in each stream's teardown/finally path). The logs route already had its own heartbeat.heartbeatIntervalMsoption/param, default 25 s) so tests run with millisecond intervals.data:line, and browserEventSourceignores comment frames natively.Testing
bun run typecheckandbun run typecheck:agentpass.src/lib/sse/__tests__/create-stats-sse-handler.test.ts(headers, flush comment, data/error frames, subscribe failure, abort teardown, heartbeat ping, dead-consumer teardown, 401).create-broadcast-sse-handler.test.tswith heartbeat ping, dead-consumer teardown, and interval-cleared-on-abort tests.sse-utils.test.tswithstartSseHeartbeatcoverage (ping cadence, isClosed stop, enqueue-throw self-clear, stop function).stats.test.ts,zfs.test.ts,containers-events.test.ts.bun test --isolate(21 web + 68 agent tests).Closes #233
🤖 Generated with Claude Code