From 60954b15f5b50881c16363196aed7b2fc7396746 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 14:47:46 +0700 Subject: [PATCH 1/6] fix(push): wire foreground revalidate + boot device reconcile --- core/push-subscriptions.js | 13 +- core/push-subscriptions.test.mjs | 31 +++ .../099-notification-transport-reliability.md | 188 ++++++++++++++++++ src/app.tsx | 94 ++++++++- src/components/settings-dialog.tsx | 73 ++----- src/lib/CLAUDE.md | 4 + src/lib/cdp-web-transport.ts | 9 +- src/lib/push-lifecycle.test.ts | 43 ++++ src/lib/push-lifecycle.ts | 35 ++++ src/lib/push-subscribe.test.ts | 111 +++++++++++ src/lib/push-subscribe.ts | 76 +++++++ 11 files changed, 609 insertions(+), 68 deletions(-) create mode 100644 docs/tasks/099-notification-transport-reliability.md create mode 100644 src/lib/push-lifecycle.test.ts create mode 100644 src/lib/push-lifecycle.ts create mode 100644 src/lib/push-subscribe.test.ts create mode 100644 src/lib/push-subscribe.ts diff --git a/core/push-subscriptions.js b/core/push-subscriptions.js index 2a3788a..76eb52f 100644 --- a/core/push-subscriptions.js +++ b/core/push-subscriptions.js @@ -7,9 +7,20 @@ export function reconcileDeviceId(existingSubs, incoming) { const existing = existingSubs.find((sub) => sub.endpoint === incoming.endpoint) if (existing && existing.deviceId) { - // Endpoint match wins; ignore any cached deviceId from the client + // Endpoint match wins — recovers a storage-wiped device that kept the same push endpoint. + // A cached client id that conflicts with the stored binding is ignored. return { deviceId: existing.deviceId, isNew: false } } + // New endpoint. Adopt the client's self-asserted deviceId when it has one: after a + // revocation/rotation the endpoint changes but the device keeps its id, and the per-device + // prefs live in ui-state keyed by that id (not in the subs file), so re-binding the id to + // the new endpoint restores them instead of orphaning. Single-user tailnet tool — the client + // is trusted to name its own device. Only mint when the client sends no id at all (legacy). + if (incoming.deviceId) { + const known = existingSubs.some((sub) => sub.deviceId === incoming.deviceId) + return { deviceId: incoming.deviceId, isNew: !known } + } + return { deviceId: randomUUID(), isNew: true } } diff --git a/core/push-subscriptions.test.mjs b/core/push-subscriptions.test.mjs index 6015781..e4c23d3 100644 --- a/core/push-subscriptions.test.mjs +++ b/core/push-subscriptions.test.mjs @@ -105,6 +105,37 @@ describe("reconcileDeviceId", () => { expect(result2.isNew).toBe(false) }) + it("adopts a client-asserted deviceId on a new endpoint (endpoint rotation / revocation recovery)", () => { + // A revoked subscription re-subscribes with a NEW endpoint but the same device keeps its + // known deviceId (localStorage intact). The per-device prefs live in ui-state keyed by that + // id, so re-binding it to the new endpoint recovers them instead of orphaning. + const existingSubs = [ + { endpoint: "https://push.example.com/api/v1/old", deviceId: "device-keep" }, + ] + const incoming = { + endpoint: "https://push.example.com/api/v1/rotated", + deviceId: "device-keep", + } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).toBe("device-keep") + expect(result.isNew).toBe(false) + }) + + it("adopts a client-asserted deviceId even when unknown to the store (first subscribe with a client-minted id)", () => { + const existingSubs = [] + const incoming = { + endpoint: "https://push.example.com/api/v1/fresh", + deviceId: "device_local_abc", + } + + const result = reconcileDeviceId(existingSubs, incoming) + + expect(result.deviceId).toBe("device_local_abc") + expect(result.isNew).toBe(true) + }) + it("does not modify the input arrays", () => { const existingSubs = [ { endpoint: "https://push.example.com/api/v1/sub1", deviceId: "device-1" }, diff --git a/docs/tasks/099-notification-transport-reliability.md b/docs/tasks/099-notification-transport-reliability.md new file mode 100644 index 0000000..2082798 --- /dev/null +++ b/docs/tasks/099-notification-transport-reliability.md @@ -0,0 +1,188 @@ +# 099 — notification & transport reliability + +- **Status:** ready +- **Mode:** AFK (the only HITL bits are device-only iOS confirmations, carved out as **non-blocking** — see Test plan) +- **Estimate:** 2–3d (ships as ONE PR with 4 internal commit boundaries) +- **Depends on:** none (builds on t040/t042 reconnect, t056 paint-ack, t070/t098 keeper, t071 sweep, t093 per-device, t095 push identity) +- **Blocks:** none + +## Goal + +Close the reliability cluster the deep-review found: **push notifications silently stop on the phone** (four P1s), and the web server/client have recovery gaps that need a restart or manual reload to escape (P2s). After this task the daily-driver iPad/iPhone PWA keeps delivering notifications across a localStorage wipe, a subscription revocation, a Slack token rotation, and a server restart; the web server survives a half-open sleeping client, a crash mid-write, and a bad POST; and the client recovers from a rejected reconnect, a poisoned downlink, and a wake-from-suspend with a stale frame — all without human intervention. This is a reliability-only task: no new user-facing features, no UI redesign. + +## Why now + +The web PWA is the priority surface and exists to triage notifications (ADR-0012). The four P1s all end in "notifications silently stop" — the highest-severity failure the product has, because the user believes they are covered and misses everything with no signal. One of them (the push-revalidation stub) means t095's headline recovery fix **never actually runs** — the code is a `// TODO` that discards the gate result, and the SW-message listener is bound to the wrong target so it is dead on every platform. The P2 hardening rides along because the same subsystems are open, and a single PR keeps the reliability story coherent. + +> **Scope note (grill 2026-07-07):** bundling all four commit areas exceeds the one-session half-day cap (~2–3d). Accepted by the user (chose the combined task knowingly). Land in dependency order with commit checkpoints — **C1 push recovery** → **C2 capture continuity** → **C3 server hardening** → **C4 client resilience**. If a session nears compaction, split at those seams rather than carrying a half-done cluster. + +## Acceptance criteria + +### C1 — push recovery (P1: revalidation stub + deviceId orphan) + +- [ ] The app **actually re-validates** the push subscription on `visibilitychange` → visible: the existing once-per-foreground gate result is consumed (not discarded), and when it fires AND push intent is on, the effectful re-subscribe runs. The current `// TODO(t095-future)` no-op is gone. +- [ ] The SW→page message listener is bound to `navigator.serviceWorker` (the container), not `navigator.serviceWorker.controller`, so a `push-subscription-change` message is actually received. The `pushsubscriptionchange` SW path stays (dead on iOS, correct on Android/desktop). +- [ ] **Boot deviceId reconcile:** on web boot, if `pushManager.getSubscription()` returns a live subscription, the app calls `subscribePush(sub)` so the server reconciles the endpoint to its prior `deviceId`, and adopts the returned id **before** reading any device-keyed ui-state (`webPush_`, `notifMutes_`, `notificationsEnabled_`). After a localStorage wipe, mutes/master/toggle again read the correct keys. +- [ ] **Intent = server flag:** after boot reconcile, if `webPush_ === false` the app unsubscribes (honoring a prior OFF choice); otherwise the live sub is kept. A live sub on boot normally implies push was on, so a wiped device self-heals silently. +- [ ] **Auto-subscribe only with recoverable intent:** a null subscription + fresh localStorage (no known deviceId) leaves push OFF (user re-enables via Settings — the wipe+revoked corner, documented as accepted). A null subscription + a known deviceId whose `webPush_ === true` re-subscribes (revocation recovery). +- [ ] The subscribe/unsubscribe/re-validate effect logic is lifted out of `settings-dialog.tsx` into a shared module so boot, the visibilitychange gate, and Settings all call one implementation (no duplicated subscribe path). + +### C2 — Slack capture continuity (P1: stale creds + memory-only watermark) + +- [ ] **Watermark survives restart:** `{ watermark, seeded }` is persisted to a new non-secret `slack-sweep-state.json` and loaded on boot. A team with a persisted watermark **resumes from it** (fetches history since the watermark, backfilling the downtime gap); only a genuinely-new team seeds from `latest`. The stale "cold start re-fetch (not re-notify)" comment is corrected. +- [ ] Persistence uses a **debounced (~2s trailing) atomic write** (write-temp → rename) and a **SIGTERM/SIGINT flush** that writes synchronously before exit. Seeding never re-fires notifications for messages already past the persisted watermark (store id-dedup makes a re-fetch idempotent). +- [ ] **Stale creds self-recover:** on `markCredsStale(team)` the notification center re-runs `extractSlackCreds` over the live side-channel socket (reads the fresh `localConfig_v2`). If the workspace is still stale on the next sweep AND the live Slack tab is the keeper's own anonymous parked tab, it `Page.reload`s that tab to force a fresh token. It **never** reloads a user-pinned Slack tab (t098) — for those it re-extracts only and lets the health surface degrade. No hijack-write fallback is added (ADR-0011 sole-writer invariant preserved; the persisted watermark backfills the heal-window gap). +- [ ] Slack notifications resume within one sweep cycle after a token rotation, with zero lost messages (delayed, not dropped) as long as the parked tab / a pin keeps one workspace live. + +### C3 — server hardening (P2) + +- [ ] **Liveness + backpressure:** the WS fan-out sends `ws.ping` on a heartbeat and `terminate`s + evicts (from `wsClients` and `paintAckClients`) a socket that misses the pong deadline. Per frame per client, if `ws.bufferedAmount` exceeds a cap the frame is **skipped for that client only** (fresh-frame-wins); a slow client is not disconnected, only a heartbeat-dead one is. A half-open sleeping iPad can no longer buffer frames unboundedly, and a dead paint-ack client can no longer pace every other viewer to ~1 fps. +- [ ] **Atomic JSON persistence:** `settings.json`, `web-push-subs.json`, `web-notifications.json`, `slack-workspaces.json`, and `slack-sweep-state.json` all write via a shared atomic write-temp-then-rename helper. A crash mid-write can no longer truncate/reset any of them. +- [ ] **Body validation:** an unparseable or undecryptable POST body yields a `400` (not a masked `{}`), and mutation routes shape-guard their input (config requires `{host,port}`-shaped; pins requires an array; etc.) so one malformed POST can no longer persist garbage that wipes pins or CDP config. +- [ ] **Sweep overlap guard:** the 15s `runOnce` backstop is single-flighted — a concurrent invocation is skipped while one is in flight, so a 429 `Retry-After` sleep can't stack duplicate sweeps into a rate-limit spiral. + +### C4 — client resilience (P2) + +- [ ] **Reconnect survives a rejected connect:** the auto-reconnect driver catches a rejected `/api/connect` POST and treats it as a failed attempt (schedules the next backoff) rather than letting the rejection escape the loop. The UI can no longer get wedged on "Reconnecting…" forever. +- [ ] **Downlink poison guard:** a single throwing listener or a single failed E2E decode no longer kills the downlink chain — each fan-out dispatch and each decode is guarded; a failed decode is dropped and counted, and a run of decode failures surfaces a "decryption failing / wrong passphrase" signal instead of silent death. The page-context `new Notification()` in `maybeToast` is guarded (it throws on iOS/Android). +- [ ] **Wake resync:** on `visibilitychange` → visible, the client probes for a fresh frame or pong within ~1.5s; if silent it calls the driver's `reconnectNow()` (t042). A frozen frame labeled "Connected" can no longer persist after a suspend that swallowed the `disconnected` broadcast. +- [ ] **Viewport quality fix:** the resize-triggered screencast reissue reads `jpegQuality` + `everyNthFrame` from the active quality tier (the same source the connect path uses) instead of hardcoding `quality: 80` and dropping `everyNthFrame`. A resize no longer silently overrides the user's tier or the t054 rate ceiling. +- [ ] **Health endpoint honors E2E:** the `/api/notifications/health` fetch routes through the same crypto bridge as every other `/api` call, so Grid grouping, exclude migration, and the health card work when `E2E_PASSPHRASE` is set. + +### Cross-cutting + +- [ ] **AFK keystone e2e:** the hermetic `test/e2e/` harness (fake CDP host + `web/server.mjs`, isolated paths) proves, with no device: (a) sweep watermark persists across a server restart and resumes rather than re-seeds; (b) a rejected connect schedules a retry instead of wedging; (c) a client over the buffered-amount cap is skipped, not served, while a healthy client still gets frames. +- [ ] No regression to `setAppBadge` mirroring, the deep-route `data` payload, `notificationclick`, tab-switch settle, the paint-ack gate, or the E2E wire format. + +## Test plan + +**AFK posture:** every gate below runs headlessly (`pnpm test`, `pnpm test:e2e`, `pnpm typecheck`, `pnpm build`, `node --check`, `pnpm web` boot). Genuinely device-only iOS behaviors are isolated in a **non-blocking** post-merge checklist so the AFK agent can build, verify, and close on green automated gates. + +### Layer 1 — Pure logic (TDD) — `pnpm test` + +- [ ] `src/lib/push-lifecycle` — `planBootPush({ hasSub, knownIntent })` → `reconcile | resubscribe | noop`; `planPostReconcile({ serverWebPush })` → `keep | unsubscribe`. Covers: live sub → reconcile; no sub + intent on → resubscribe; no sub + unknown intent → noop; reconciled flag false → unsubscribe. +- [ ] `src/lib/push-revalidate` (existing) — assert the gate is actually consumed by adding a test that the foreground-revalidate decision = `gate.shouldRevalidateNow(visible) && intentOn` (guards the wired behavior, not just the gate). +- [ ] `core/slack-sweep-state` — `serialize`/`deserialize` round-trip of `{ watermark, seeded }` (Set ↔ array); `createSweepStatePersister({ read, write, now, setTimer })` debounce coalesces a burst into one trailing flush; `flushSync` writes immediately; load-on-boot returns `{}`-defaults on a missing/corrupt file. +- [ ] `core/atomic-write` — `atomicWriteFileSync(path, data, { fs })` writes to a temp path then renames; a write that throws leaves the original file intact (inject a failing fs). +- [ ] `core/ws-backpressure` — `shouldSkipClient(bufferedAmount, cap)` boolean; `livenessVerdict(lastPongAt, now, deadline)` → `alive | dead`. +- [ ] `core/request-guards` — `isValidConfig`, `isValidPins`, and a `parseBodyOrReject` sentinel: valid shapes pass, malformed/`null`/wrong-type reject. +- [ ] `core/sweep-overlap` (or extend `sweep-scheduler`) — a single-flight guard skips a concurrent `runOnce` while one is in flight and re-arms after it settles. +- [ ] `src/lib/wake-resync` — `planWakeResync({ visible, sawFrameWithinMs, sawPongWithinMs })` → `reconnect | noop`. +- [ ] `src/lib/web-reconnect-driver` (existing test) — add: a connect thunk that rejects schedules the next backoff and does not throw. +- [ ] `src/lib/downlink-dispatcher` (existing test) — add: a throwing listener does not prevent other listeners from receiving; a decode-throw is isolated to that message. + +### Layer 2 — Automated integration (`pnpm test:e2e` + boot checks) + +- [ ] **Sweep restart keystone:** start server, ingest a swept entry advancing a watermark, stop, restart with the same `SLACK_SWEEP_STATE_PATH` → the reloaded state resumes from the watermark (seed branch NOT taken); assert via the sweep runner's plan. +- [ ] **Reconnect keystone:** a `/api/connect` that rejects (fake host returns error) leaves the driver scheduling a retry (state observable), not a wedged terminal. +- [ ] **Backpressure keystone:** a simulated client whose `bufferedAmount` exceeds the cap is skipped for a frame while a second healthy client still receives it. +- [ ] `node --check web/server.mjs` + `node --check main.js`; `pnpm web` boots cleanly against the fake CDP host; existing `server.e2e.test.ts` + `resilience.e2e.test.ts` stay green. +- [ ] **Mocked SW push-flow** (jsdom/unit): a fabricated `push` event → `buildNotificationContent` → asserts `showNotification` is called and the boot deviceId-adopt path runs on a reconciled response. + +### Layer 3 — Visual review + +- [ ] n/a — no renderer UI layout changes. The only user-visible surfaces are OS notifications, the status-bar connection state (recovers instead of freezing), and the screencast sharpness after resize (verified in the device checklist, not a screenshot diff). + +### Post-merge device confirmation (HITL — NON-BLOCKING, does not gate AFK close) + +Logged for the next real-device session; the automated gates above are sufficient to close AFK. + +- [ ] Real installed iOS PWA: a lock-screen push arrives; the home-screen badge count is correct. +- [ ] Storage-wipe recovery: after a wipe + foreground, push still delivers and prior mutes/master are restored (server reconciled the same `deviceId`). +- [ ] Revocation recovery: a force-revoked subscription self-heals on the next foreground. +- [ ] Slack token rotation: notifications resume within a sweep cycle with no lost messages. +- [ ] Suspend/resume: waking the PWA after a network drop shows a live frame (no frozen "Connected"), and a resize keeps the chosen quality tier. + +## Design notes + +Describe behavioral contracts, not file paths. + +- **Contracts changed:** + - Boot push flow — `getOrCreateDeviceId` is no longer trusted forever; on web boot, a live subscription drives an endpoint reconcile that adopts the server's `deviceId` before any device-keyed read. The Settings toggle displays state derived from (live sub ∧ server flag), not localStorage alone. + - Slack sweep state — was module-only in-memory; now `{ watermark, seeded }` is a persisted, reloaded contract with debounced-atomic write + shutdown flush. `markCredsStale(team)` gains a self-recovery side effect (re-extract over the live socket; conditional parked-tab reload). + - WS fan-out — gains a per-client send predicate (skip on buffered-amount cap) and a heartbeat/eviction lifecycle; no wire-format change. + - `POST` body handling — a parse/decrypt failure is a `400`, not a silent `{}`; mutation routes shape-guard. + - Reconnect driver — a rejected connect is an internal failed attempt, never an escaped rejection. + - `/api/notifications/health` — now crossed the crypto bridge like other `/api` calls (E2E-transparent). +- **New modules (all with a testable pure core):** + - `src/lib/push-lifecycle.ts` — pure boot/post-reconcile planners. + - `src/lib/push-subscribe.ts` — effectful subscribe/unsubscribe/re-validate glue lifted from `settings-dialog.tsx`, DI'd, importing the planners. + - `src/lib/wake-resync.ts` — pure wake-resync decision. + - `core/atomic-write.js` — atomic write-temp-rename helper (server + main). + - `core/slack-sweep-state.js` — pure serialize/deserialize + DI'd debounced persister. + - `core/ws-backpressure.js` — pure skip/liveness predicates. + - `core/request-guards.js` — pure body/shape validation. + - (sweep single-flight: extend `core/sweep-scheduler.js` if it fits, else `core/sweep-overlap.js`.) +- **New ADR needed?** **Yes — ADR-0016 "persist Slack sweep watermark across restarts."** Passes the 3/3 bar: hard to reverse (on-disk format + resume semantics), surprising to a future reader (why a new state file), a real trade-off (persist-and-resume vs memory-only; resume-from-watermark vs seed-from-`last_read`). Draft during C2 implementation once the shape is concrete. It extends ADR-0011 (which claimed cold-start was re-fetch-not-re-notify — this makes that true). + +```ts +// C1 — pure boot planners (src/lib/push-lifecycle.ts) +type Intent = "on" | "off" | "unknown" +planBootPush(i: { hasSub: boolean; knownIntent: Intent }): "reconcile" | "resubscribe" | "noop" +// hasSub -> "reconcile" (adopt server deviceId by endpoint) +// !hasSub & on -> "resubscribe" (recover revocation) +// else -> "noop" (fresh localStorage + no sub = leave OFF) +planPostReconcile(i: { serverWebPush: boolean }): "keep" | "unsubscribe" + +// C2 — persisted sweep state (core/slack-sweep-state.js) +serialize(state: { watermark; seeded: Set }): { watermark; seeded: string[] } +createSweepStatePersister(deps: { read; write; now; setTimer; debounceMs }) +// .load() -> { watermark, seeded } .scheduleFlush() .flushSync() +``` + +## Out of scope + +Captured as separate tasks / backlog: + +- **t100 — durable client prefs:** move `qualityTier` / `inputTransport` / `latencyHud` off localStorage into device-keyed server ui-state (reuse the t093/t095 remap seam). Same wipe root cause, but a UI-persistence concern needing its own visual review of the Settings pickers. +- **Task/doc hygiene:** close the still-open `t096`/`t098` task files, diet the ~99KB `CLAUDE.md` + `src/lib/CLAUDE.md`, adopt `checkJs` for the untyped backend. Separate cleanup. +- **Feature backlog** (each its own task, not reliability): Slack reactions in the reader, inbox swipe triage, per-device quiet hours, Slack unread catch-up view, hardware-keyboard Telex/IME bridge, workspace-qualified push titles. +- Read-state cross-device sync (a silent push to sync would risk `userVisibleOnly` revocation on iOS); seed-from-`last_read` unread backfill (that's the catch-up feature); pausing the screencast when zero clients (an optimization, not the leak fix); the push-content threat model. +- Electron parity for any of the above (Electron is best-effort; the P2 client/server fixes are web-path). + +## Definition of Done + +**AFK completion gates — all required to close (no device needed):** + +- [ ] Layer 1 tests written and green (all pure planners/helpers above) +- [ ] Layer 2 green: the three e2e keystones + mocked SW push-flow pass; `node --check` both backends; `pnpm web` boots against the fake CDP host +- [ ] `pnpm test` green; `pnpm test:e2e` green +- [ ] `pnpm typecheck` clean; `pnpm check:changed` clean (Biome on the diff); `pnpm build` clean +- [ ] CLAUDE.md (web-build push + Slack sweep + web-server bullets) + `src/lib/CLAUDE.md` (new modules) + `core/` module comments updated +- [ ] **ADR-0016 written** (persist Slack sweep watermark) — authored during C2 +- [ ] No commented-out code, no stray `console.log`, no AI attribution +- [ ] Task closed: status → done, moved to `docs/tasks/done/`, `t099` in branch + commit +- [ ] Branch `t099-notification-transport-reliability`; 4 commits at the C1–C4 boundaries with semantic titles; **push + PR authorized by the user (grill 2026-07-07)** — open the PR when gates are green + +**Non-blocking (do NOT gate AFK close):** the post-merge device confirmation checklist — run on the next device session and note results in the closed task. + +## Notes + +Origin: the ultracode deep-review workflow (2026-07-07) — 54 adversarially-verified findings; this task takes the confirmed P1 cluster + the notification/transport P2 hardening. Grilled via `/grill-with-docs`, spec via `/to-prd`. + +**Verified findings driving each criterion (file:line at review time — verify before editing, paths drift):** +- Revalidation stub — `app.tsx:263` discards `shouldRevalidateNow()` behind a `// TODO(t095-future)`; the SW-message listener is on `navigator.serviceWorker.controller` (`app.tsx:367`) so it never receives (messages arrive on the container) — dead on ALL platforms, not just iOS. `reValidateSubscription` already exists whole at `settings-dialog.tsx:297`. +- deviceId orphan — `getOrCreateDeviceId` mints from localStorage (`cdp-web-transport.ts:46`, adopt seam at `:359`); after the documented iPad wipe, `webPush_` reads false while pushes keep arriving and mutes write to keys delivery never reads. The push **endpoint survives** the wipe (SW/IndexedDB), so `reconcileDeviceId(pushSubs, sub)` (`server.mjs:1079`) recovers the old id — a boot re-subscribe heals it. +- Stale creds — extraction only in `ws.on("open")` (`notifications-sidechain.js:229`); `markCredsStale` (`:361`) keeps last creds but nothing re-extracts. The parked-tab socket stays OPEN through a token rotation, so re-running `extractSlackCreds` over it reads the fresh `localConfig_v2`. +- Watermark memory-only — `slackSweepState` (`server.mjs:682`) is module-level; only settings/subs/notifs/workspaces persist. The `:680-681` comment claiming cold-start is "re-fetch not re-notify" is wrong — the seed branch skips the fetch (`slack-sweep-runner.js` seed path), so downtime messages are dropped. +- WS backpressure — `broadcastFrameBinaryRaw`/`broadcast` (`server.mjs:277`/`:357`) evict only on a send-throw, which never fires on a half-open socket; no `bufferedAmount`, `ws.ping`, or `terminate` anywhere. A dead paint-ack client keeps `paintAckActive()` true and the watchdog paces others to ~1 fps. +- Non-atomic writes — every persist is a bare `writeFileSync` (`:127`, `:141`, `:547`, `:612`). +- body() masking — a bad/undecryptable body becomes `{}`; mutation routes don't shape-validate. +- Sweep overlap — `setInterval(runOnce, 15s)` (`server.mjs:802`) has no in-flight guard; a 429 `Retry-After` sleep can stack sweeps. +- Reconnect wedge, downlink poison, frozen-frame-on-wake, viewport `quality:80` hardcode, `/api/notifications/health` raw-fetch — client-side, confirmed in the review (dimensions stability-client + drift). + +**Grill decisions (2026-07-07):** +1. Scope = one combined task, one PR, 4 commit boundaries (C1–C4) in dependency order. +2. Push intent after reconcile = the durable server `webPush_` flag; unsubscribe if false. +3. Auto-subscribe only with recoverable intent; the wipe+revoked corner is user-fixable (accepted). +4. Stale-cred recovery = re-extract over the live socket; reload only the keeper's own parked tab, never a user pin; no hijack-write fallback. +5. Watermark = new `slack-sweep-state.json`, debounced (~2s) atomic write + SIGTERM/SIGINT flush; resume-from-watermark for known teams, seed-from-`latest` for new. +6. WS backpressure = drop frames for an over-cap client; only the heartbeat disconnects. +7. Wake resync = probe on foreground, `reconnectNow()` if silent < ~1.5s. +8. Durable client prefs migration = deferred to t100. +9. Verify bar = green gates + mocked SW flow + a non-blocking device checklist. +10. Process = build + push + open PR (user authorized); 4 semantic commits, no AI attribution. + +--- + +_When task status flips to `done`, move this file to `done/`._ diff --git a/src/app.tsx b/src/app.tsx index 5a28d8c..ed9da90 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,8 +36,15 @@ import { } from "@/lib/notification-activation" import { threadKey } from "@/lib/notifications-view" import { dropDeadLinks, pinForTarget, resolvePinLink } from "@/lib/pins" +import { planBootPush, planForegroundRevalidate, planPostReconcile } from "@/lib/push-lifecycle" import { createPushRevalidateGate } from "@/lib/push-revalidate" import { notifIdFromSearch, resolvePushEntry, stripNotifParam } from "@/lib/push-route" +import { + createBrowserPushDeps, + ensurePushSubscription, + getExistingSubscription, + removePushSubscription, +} from "@/lib/push-subscribe" import { shouldApplyAdaptive } from "@/lib/shell-mode" import { addExclude, @@ -249,22 +256,87 @@ export default function App() { window.addEventListener("popstate", onPop) return () => window.removeEventListener("popstate", onPop) }, []) - // E1b: Re-validate push subscription on app foreground (iOS PWA recovery path). - // pushsubscriptionchange never fires on iOS PWAs, so the only recovery hook is - // visibilitychange. Once-per-foreground gate prevents spam; hidden→visible resets - // the gate for the next foreground. The settings dialog's reValidateSubscription - // handles the actual subscription refresh (deferred: t095-note in task file). + // E1b (t099): re-validate the push subscription on app foreground — the iOS PWA recovery + // path, since `pushsubscriptionchange` never fires there. A once-per-foreground gate + // prevents spam; intent is the durable server flag, read fresh each foreground. useEffect(() => { - const pushRevalidateGate = createPushRevalidateGate() + if (!caps.web) return + const gate = createPushRevalidateGate() + const deps = createBrowserPushDeps() const onVisibilityChange = () => { - const visible = document.visibilityState === "visible" - // Gate fires once per foreground; reset on hidden→visible transition. - pushRevalidateGate.shouldRevalidateNow(visible) - // TODO(t095-future): call reValidateSubscription when it's on the bridge + const gateFired = gate.shouldRevalidateNow(document.visibilityState === "visible") + if (!gateFired) return + window.cdp.getUiState().then((s) => { + if (planForegroundRevalidate({ gateFired, intentOn: !!s.webPush })) { + ensurePushSubscription(deps).catch((e) => + console.error("[push] foreground revalidate failed:", e), + ) + } + }) } document.addEventListener("visibilitychange", onVisibilityChange) return () => document.removeEventListener("visibilitychange", onVisibilityChange) - }, []) + }, [caps.web]) + + // Boot deviceId reconcile (t099): after a localStorage wipe the client mints a fresh + // deviceId, but the push endpoint (SW/IndexedDB) survives — so re-registering a live + // subscription lets the server map endpoint→prior deviceId, which we adopt BEFORE reading + // any device-keyed ui-state (mutes/master/toggle). Intent = the durable server flag: keep + // the sub when it says on, drop it when it says the user had turned push off. With no live + // sub we only re-subscribe when a known device declared intent (fresh wipe stays OFF). + useEffect(() => { + if (!caps.web) return + let cancelled = false + const deps = createBrowserPushDeps() + ;(async () => { + try { + const sub = await getExistingSubscription(deps) + const before = await window.cdp.getUiState() + const plan = planBootPush({ + hasSub: !!sub, + knownIntent: before.webPush ? "on" : "unknown", + }) + if (cancelled || plan === "noop") return + const res = await ensurePushSubscription(deps) // adopts the reconciled deviceId + if (cancelled || !res) return + if (plan === "reconcile") { + const after = await window.cdp.getUiState() + if (cancelled) return + // Refresh the device-keyed React state under the reconciled id. + setNotificationsEnabled(after.notificationsEnabled ?? true) + setNotifMutes(Array.isArray(after.notifMutes) ? after.notifMutes : []) + if (planPostReconcile({ serverWebPush: !!after.webPush }) === "unsubscribe") { + await removePushSubscription(deps) + } + } + } catch (e) { + console.error("[push] boot reconcile failed:", e) + } + })() + return () => { + cancelled = true + } + }, [caps.web]) + + // SW push-subscription-change recovery (t099): fires on Android/desktop (dead on iOS, where + // the foreground gate above covers it). Bound to the container (`navigator.serviceWorker`), + // not `.controller` — messages arrive on the container. Re-validates when push intent is on. + useEffect(() => { + if (!caps.web) return + const deps = createBrowserPushDeps() + const onMessage = (event: Event) => { + if ((event as MessageEvent).data?.type !== "push-subscription-change") return + window.cdp.getUiState().then((s) => { + if (s.webPush) { + ensurePushSubscription(deps).catch((e) => + console.error("[push] SW-change revalidate failed:", e), + ) + } + }) + } + navigator.serviceWorker?.addEventListener("message", onMessage) + return () => navigator.serviceWorker?.removeEventListener("message", onMessage) + }, [caps.web]) // Pull-to-refresh action for the phone Inbox (UX): re-fetch the swept notification list. // A yank-to-refresh is exactly when the link may be down — fail quietly (the hook's // `finally` still clears the spinner) rather than throw an unhandled rejection. diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index e3de60e..9372f63 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -1,6 +1,6 @@ import { ArrowReloadHorizontalIcon, Cancel01Icon, Settings01Icon } from "@hugeicons/core-free-icons" import { HugeiconsIcon } from "@hugeicons/react" -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { readLatencyHudEnabled, setLatencyHudEnabled } from "@/components/latency-hud" import { AlertDialog, @@ -28,6 +28,11 @@ import { Switch } from "@/components/ui/switch" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { isPointerFine, usePointerCoarse } from "@/hooks/use-pointer-coarse" import { getCaps } from "@/lib/caps" +import { + createBrowserPushDeps, + ensurePushSubscription, + removePushSubscription, +} from "@/lib/push-subscribe" import { parseTier, QUALITY_TIER_KEY, QUALITY_TIERS, type QualityTier } from "@/lib/quality-tier" import { shouldArmLeaveTimer } from "@/lib/settings-dismiss" import { removeExclude, type SlackExclude } from "@/lib/slack-excludes" @@ -40,18 +45,6 @@ import { type VirtualPointerMode, } from "@/lib/virtual-pointer" -// VAPID public key is delivered as URL-safe base64 by the server; pushManager.subscribe -// expects a raw ArrayBuffer. Standard helper from the Web Push spec. -function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4) - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") - const rawData = atob(base64) - const buf = new ArrayBuffer(rawData.length) - const view = new Uint8Array(buf) - for (let i = 0; i < rawData.length; i++) view[i] = rawData.charCodeAt(i) - return buf -} - export type SwitchEffect = "none" | "blur" | "grayscale" | "blur-grayscale" // One row of the Slack capture health report (t074), as returned by /api/notifications/health. @@ -293,22 +286,18 @@ export function SettingsDialog({ // the drawer reopens where it was left, surviving a refresh — restored on open, saved on close. const scrollRef = useRef(null) - // Re-subscribe when the SW detects a push-subscription-change (rotation/revocation). + // One subscribe implementation, shared with app.tsx's boot reconcile + foreground + // recovery (t099) — no second copy of the pushManager.subscribe dance here. + const pushDeps = useMemo(() => createBrowserPushDeps(), []) + + // Re-subscribe when Settings opens with push on (catches rotation/revocation); idempotent. const reValidateSubscription = useCallback(async () => { try { - const reg = await navigator.serviceWorker?.ready - if (!reg) return - const key = await window.cdp.getPushVapidKey?.() - if (!key) return - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToArrayBuffer(key), - }) - await window.cdp.subscribePush?.(sub.toJSON() as PushSubscriptionJSON) + await ensurePushSubscription(pushDeps) } catch (e) { console.error("[push] re-validate subscription failed:", e) } - }, []) + }, [pushDeps]) const toggleWebPush = useCallback( async (on: boolean) => { @@ -327,28 +316,13 @@ export function SettingsDialog({ // Wrapped in try/catch since service workers / pushManager may not exist on every // browser; failures here don't undo the ui-state toggle (the user can retry). try { - const reg = await navigator.serviceWorker?.ready - if (!reg) return - if (on) { - const key = await window.cdp.getPushVapidKey?.() - if (!key) return - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToArrayBuffer(key), - }) - await window.cdp.subscribePush?.(sub.toJSON() as PushSubscriptionJSON) - } else { - const sub = await reg.pushManager.getSubscription() - if (sub) { - await window.cdp.unsubscribePush?.(sub.endpoint) - await sub.unsubscribe() - } - } + if (on) await ensurePushSubscription(pushDeps) + else await removePushSubscription(pushDeps) } catch (e) { console.error("[push] subscribe/unsubscribe failed:", e) } }, - [reValidateSubscription], + [pushDeps], ) // Suppress the leave-timer while a Select popover (portaled outside the panel) @@ -356,19 +330,8 @@ export function SettingsDialog({ const [selectOpen, setSelectOpen] = useState(false) const leaveTimer = useRef | undefined>(undefined) - // Listen for push subscription changes from the service worker (rotation/revocation). - useEffect(() => { - const handleSWMessage = (event: Event) => { - const e = event as MessageEvent - if (e.data?.type === "push-subscription-change" && webPush) { - reValidateSubscription() - } - } - navigator.serviceWorker?.controller?.addEventListener("message" as string, handleSWMessage) - return () => { - navigator.serviceWorker?.controller?.removeEventListener("message" as string, handleSWMessage) - } - }, [webPush, reValidateSubscription]) + // The SW push-subscription-change listener lives in app.tsx now (always mounted, correct + // container target, reads intent fresh) — see t099. Settings only re-validates on open. useEffect(() => { if (open) { diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index 71a1c36..b6afbf5 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -46,6 +46,10 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`push-revalidate.ts`** — Pure once-per-foreground subscription re-validation gate (t095, E1b, ADR-0014). `createPushRevalidateGate()` returns `{ shouldRevalidateNow(visible) }` — a tiny state machine that arms on hidden→visible transition, fires `true` once per foreground stay, then locks out until the next hide. `app.tsx` calls this on `visibilitychange` and re-subscribes when it fires, recovering a revoked subscription before the next push arrives. Tested by `push-revalidate.test.ts`. +**`push-lifecycle.ts`** — Pure planners for the web push subscription lifecycle (t099, C1). `planBootPush({ hasSub, knownIntent })` → `reconcile | resubscribe | noop` (live sub → adopt server deviceId by endpoint; no sub + known intent on → recover revocation; else stay OFF so a fresh localStorage wipe never resurrects push the user turned off). `planPostReconcile({ serverWebPush })` → `keep | unsubscribe` (the durable server flag is the source of truth once the reconcile recovered the real deviceId). `planForegroundRevalidate({ gateFired, intentOn })` → boolean (guards the *wired* revalidate, not just the gate). Pure; the effects live in `push-subscribe.ts` + `app.tsx`. Tested by `push-lifecycle.test.ts`. + +**`push-subscribe.ts`** — Effectful web push subscribe helpers behind a DI seam (t099, C1). `ensurePushSubscription(deps)` subscribes (idempotent — doubles as re-validate) and registers with the server, returning the reconciled `{ deviceId }`; `getExistingSubscription(deps)` reads the live sub without creating one (boot-planning input); `removePushSubscription(deps)` tears down server-side + browser-side. `createBrowserPushDeps()` is the single adapter binding the deps to `navigator.serviceWorker` + the `window.cdp` push bridge — the only place that knows the bridge method names. `urlBase64ToArrayBuffer` (VAPID key decode) moved here from `settings-dialog.tsx`. `app.tsx` (boot reconcile, foreground revalidate, SW push-subscription-change) and `settings-dialog.tsx` (toggle, on-open revalidate) share this one implementation. Tested by `push-subscribe.test.ts` (fakes). + **`screencast-keys.ts`** — Pure OSK key helpers for the on-screen keyboard bridge (t084/t086). `VKEY` maps Web key names to Windows virtual-key codes (`keyCode`) — the field `Input.dispatchKeyEvent` uses; a 0 means the remote ignores the key. `KEYDOWN_KEYS` is the set of non-text keys forwarded from `keydown` (Enter, Tab, Escape, arrows, Home/End/Delete — Backspace is absent, routed by the input-delta path instead). `synthKey(key)` builds the `SynthKey` payload with zeroed modifier flags. Kept outside the component so the vkey mapping and routing decisions are unit-testable (`screencast-keys.test.ts`). **`text-input-delta.ts`** — Pure on-screen-keyboard diff (t084). `diffInput(prev, next)` returns `{ backspaces, insert }` — the minimal delete-from-tail + insert that turns `prev` into `next`, computed from their longest common prefix. Diffing (not per-keystroke capture) is what makes autocorrect, predictive text, paste, and composing input (Vietnamese Telex, CJK IME) work: the hidden field holds the composed result and we sync the delta. Pure. Tested by `text-input-delta.test.ts`. diff --git a/src/lib/cdp-web-transport.ts b/src/lib/cdp-web-transport.ts index eebd0bc..c7bd8b4 100644 --- a/src/lib/cdp-web-transport.ts +++ b/src/lib/cdp-web-transport.ts @@ -862,7 +862,14 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge // E0: Subscribe without local deviceId; server reconciles by endpoint and returns the // authoritative id. Renderer adopts it, updating localStorage + ui-state keys. subscribePush: async (sub) => { - const result = (await rest.postJson("/api/notifications/subscribe", sub as object)) as { + // Send this device's current id alongside the subscription. On an endpoint match the + // server ignores it (endpoint wins — storage-wipe recovery); on a NEW endpoint (revocation + // / rotation) the server adopts it so the per-device prefs keyed by that id survive + // instead of orphaning (t099). Absent-id legacy path still mints server-side. + const result = (await rest.postJson("/api/notifications/subscribe", { + ...(sub as object), + deviceId, + })) as { deviceId: string } setDeviceId(result.deviceId) diff --git a/src/lib/push-lifecycle.test.ts b/src/lib/push-lifecycle.test.ts new file mode 100644 index 0000000..393e3bf --- /dev/null +++ b/src/lib/push-lifecycle.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { planBootPush, planForegroundRevalidate, planPostReconcile } from "./push-lifecycle" + +describe("planBootPush", () => { + it("reconciles when a live subscription exists (adopt server deviceId by endpoint)", () => { + expect(planBootPush({ hasSub: true, knownIntent: "on" })).toBe("reconcile") + expect(planBootPush({ hasSub: true, knownIntent: "off" })).toBe("reconcile") + expect(planBootPush({ hasSub: true, knownIntent: "unknown" })).toBe("reconcile") + }) + + it("re-subscribes when no sub but a known device wants push (revocation recovery)", () => { + expect(planBootPush({ hasSub: false, knownIntent: "on" })).toBe("resubscribe") + }) + + it("does nothing when no sub and intent is off or unknown (fresh wipe stays OFF)", () => { + expect(planBootPush({ hasSub: false, knownIntent: "off" })).toBe("noop") + expect(planBootPush({ hasSub: false, knownIntent: "unknown" })).toBe("noop") + }) +}) + +describe("planPostReconcile", () => { + it("keeps the subscription when the server flag says push is on", () => { + expect(planPostReconcile({ serverWebPush: true })).toBe("keep") + }) + + it("unsubscribes when the server flag says push was turned off", () => { + expect(planPostReconcile({ serverWebPush: false })).toBe("unsubscribe") + }) +}) + +describe("planForegroundRevalidate", () => { + it("revalidates only when the once-per-foreground gate fired and intent is on", () => { + expect(planForegroundRevalidate({ gateFired: true, intentOn: true })).toBe(true) + }) + + it("does not revalidate when the gate did not fire", () => { + expect(planForegroundRevalidate({ gateFired: false, intentOn: true })).toBe(false) + }) + + it("does not revalidate when push intent is off", () => { + expect(planForegroundRevalidate({ gateFired: true, intentOn: false })).toBe(false) + }) +}) diff --git a/src/lib/push-lifecycle.ts b/src/lib/push-lifecycle.ts new file mode 100644 index 0000000..c905b59 --- /dev/null +++ b/src/lib/push-lifecycle.ts @@ -0,0 +1,35 @@ +// Pure planners for the web-build push subscription lifecycle (t099, C1). +// The effectful subscribe/unsubscribe/reconcile glue lives in `push-subscribe.ts` +// and `app.tsx`; these functions only encode the decision so it can be unit-tested. +// See docs/tasks/done/099-*.md and ADR-0014 (endpoint-reconciled per-device identity). + +// What the durable server-side `webPush_` flag says the user wants, or +// "unknown" when we can't read it yet (fresh localStorage — no deviceId to key by). +export type PushIntent = "on" | "off" | "unknown" + +// Boot decision. A live subscription is always reconciled first (adopt the server's +// endpoint-bound deviceId, then decide keep/drop via planPostReconcile). With no live +// subscription we only re-subscribe when a known device declared intent — a fresh +// wipe (unknown intent) stays OFF so we never resurrect push the user turned off. +export function planBootPush(input: { + hasSub: boolean + knownIntent: PushIntent +}): "reconcile" | "resubscribe" | "noop" { + if (input.hasSub) return "reconcile" + return input.knownIntent === "on" ? "resubscribe" : "noop" +} + +// Post-reconcile decision. Once the endpoint reconcile has recovered the real +// deviceId, the durable server flag is the source of truth for whether push stays on. +export function planPostReconcile(input: { serverWebPush: boolean }): "keep" | "unsubscribe" { + return input.serverWebPush ? "keep" : "unsubscribe" +} + +// Foreground re-validate decision — the once-per-foreground gate must have fired and +// push intent must be on. Kept here so the wired behavior (not just the gate) is tested. +export function planForegroundRevalidate(input: { + gateFired: boolean + intentOn: boolean +}): boolean { + return input.gateFired && input.intentOn +} diff --git a/src/lib/push-subscribe.test.ts b/src/lib/push-subscribe.test.ts new file mode 100644 index 0000000..65979be --- /dev/null +++ b/src/lib/push-subscribe.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest" +import { + ensurePushSubscription, + getExistingSubscription, + type PushSubscribeDeps, + removePushSubscription, + urlBase64ToArrayBuffer, +} from "./push-subscribe" + +function fakeSub(endpoint = "https://push.example/ep") { + return { + endpoint, + toJSON: () => ({ endpoint, keys: { p256dh: "k", auth: "a" } }), + unsubscribe: vi.fn(async () => true), + } +} + +function fakeReg(sub: ReturnType | null) { + return { + pushManager: { + getSubscription: vi.fn(async () => sub), + subscribe: vi.fn(async () => sub ?? fakeSub()), + }, + } +} + +function deps(over: Partial = {}): PushSubscribeDeps { + return { + swReady: async () => fakeReg(fakeSub()) as unknown as ServiceWorkerRegistration, + getVapidKey: async () => "dGVzdA", + registerSubscription: vi.fn(async () => ({ deviceId: "D1" })), + unregisterSubscription: vi.fn(async () => ({ ok: true })), + ...over, + } +} + +describe("urlBase64ToArrayBuffer", () => { + it("decodes url-safe base64 to a byte buffer", () => { + const buf = urlBase64ToArrayBuffer("dGVzdA") // "test" + expect(Array.from(new Uint8Array(buf))).toEqual([116, 101, 115, 116]) + }) +}) + +describe("ensurePushSubscription", () => { + it("subscribes and registers the subscription JSON, returning the server deviceId", async () => { + const register = vi.fn(async () => ({ deviceId: "D9" })) + const d = deps({ registerSubscription: register }) + + const result = await ensurePushSubscription(d) + + expect(register).toHaveBeenCalledWith({ + endpoint: "https://push.example/ep", + keys: { p256dh: "k", auth: "a" }, + }) + expect(result).toEqual({ deviceId: "D9" }) + }) + + it("returns null when no service worker registration is available", async () => { + const result = await ensurePushSubscription(deps({ swReady: async () => null })) + expect(result).toBeNull() + }) + + it("returns null when the VAPID key is missing", async () => { + const result = await ensurePushSubscription(deps({ getVapidKey: async () => null })) + expect(result).toBeNull() + }) +}) + +describe("getExistingSubscription", () => { + it("returns the live subscription without creating one", async () => { + const sub = fakeSub() + const d = deps({ swReady: async () => fakeReg(sub) as unknown as ServiceWorkerRegistration }) + + const result = await getExistingSubscription(d) + + expect(result).toBe(sub) + }) + + it("returns null when there is no subscription", async () => { + const d = deps({ swReady: async () => fakeReg(null) as unknown as ServiceWorkerRegistration }) + expect(await getExistingSubscription(d)).toBeNull() + }) +}) + +describe("removePushSubscription", () => { + it("unregisters server-side and unsubscribes in the browser", async () => { + const sub = fakeSub("https://push.example/gone") + const unregister = vi.fn(async () => ({ ok: true })) + const d = deps({ + swReady: async () => fakeReg(sub) as unknown as ServiceWorkerRegistration, + unregisterSubscription: unregister, + }) + + await removePushSubscription(d) + + expect(unregister).toHaveBeenCalledWith("https://push.example/gone") + expect(sub.unsubscribe).toHaveBeenCalled() + }) + + it("no-ops when there is no subscription", async () => { + const unregister = vi.fn(async () => ({ ok: true })) + const d = deps({ + swReady: async () => fakeReg(null) as unknown as ServiceWorkerRegistration, + unregisterSubscription: unregister, + }) + + await removePushSubscription(d) + + expect(unregister).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/push-subscribe.ts b/src/lib/push-subscribe.ts new file mode 100644 index 0000000..182c936 --- /dev/null +++ b/src/lib/push-subscribe.ts @@ -0,0 +1,76 @@ +// Effectful web-build push subscription helpers (t099, C1). The pure decisions live in +// push-lifecycle.ts; these wrap the browser PushManager + the server register/unregister +// calls behind a DI seam so app.tsx (boot reconcile, foreground revalidate, SW-message +// recovery) and settings-dialog.tsx (toggle, on-open revalidate) share one implementation. +// Kept out of the components so the subscribe wiring is unit-testable with fakes. + +export interface PushSubscribeDeps { + // `navigator.serviceWorker?.ready` + swReady(): Promise + // `window.cdp.getPushVapidKey?.()` + getVapidKey(): Promise + // `window.cdp.subscribePush(sub)` — POSTs to the server, which reconciles + returns deviceId. + registerSubscription(sub: PushSubscriptionJSON): Promise<{ deviceId: string }> + // `window.cdp.unsubscribePush(endpoint)` + unregisterSubscription(endpoint: string): Promise +} + +// VAPID public key is delivered as URL-safe base64 by the server; pushManager.subscribe +// expects a raw ArrayBuffer. Standard helper from the Web Push spec. +export function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") + const rawData = atob(base64) + const buf = new ArrayBuffer(rawData.length) + const view = new Uint8Array(buf) + for (let i = 0; i < rawData.length; i++) view[i] = rawData.charCodeAt(i) + return buf +} + +// Adapter binding the deps to the live browser globals + the web `window.cdp` bridge. +// The single place that knows the bridge method names; components/app.tsx call it. +export function createBrowserPushDeps(): PushSubscribeDeps { + return { + swReady: () => navigator.serviceWorker?.ready ?? Promise.resolve(null), + getVapidKey: () => window.cdp.getPushVapidKey?.() ?? Promise.resolve(null), + registerSubscription: (sub) => + window.cdp.subscribePush?.(sub) ?? Promise.reject(new Error("subscribePush unavailable")), + unregisterSubscription: (endpoint) => + window.cdp.unsubscribePush?.(endpoint) ?? Promise.resolve(), + } +} + +// The current push subscription, without creating one — the boot-planning input. +export async function getExistingSubscription( + deps: PushSubscribeDeps, +): Promise { + const reg = await deps.swReady() + return (await reg?.pushManager.getSubscription()) ?? null +} + +// Ensure a live subscription exists and is registered with the server. pushManager.subscribe +// is idempotent (returns the existing sub when already subscribed), so this doubles as +// subscribe (fresh) and re-validate (recover rotation/revocation). Returns the server's +// reconciled { deviceId }, or null when the environment can't subscribe (no SW / no key). +export async function ensurePushSubscription( + deps: PushSubscribeDeps, +): Promise<{ deviceId: string } | null> { + const reg = await deps.swReady() + if (!reg) return null + const key = await deps.getVapidKey() + if (!key) return null + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToArrayBuffer(key), + }) + return deps.registerSubscription(sub.toJSON() as PushSubscriptionJSON) +} + +// Tear down the live subscription both server-side (drop the record) and in the browser. +export async function removePushSubscription(deps: PushSubscribeDeps): Promise { + const reg = await deps.swReady() + const sub = await reg?.pushManager.getSubscription() + if (!sub) return + await deps.unregisterSubscription(sub.endpoint) + await sub.unsubscribe() +} From c2eaf805ca98ee7afb1995d6a0a26dae95977a93 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 15:00:44 +0700 Subject: [PATCH 2/6] fix(slack): persist sweep watermark + re-extract stale creds --- CLAUDE.md | 4 +- core/atomic-write.js | 15 ++++ core/atomic-write.test.ts | 33 +++++++ core/notifications-sidechain.js | 45 ++++++++-- core/notifications-sidechain.test.ts | 44 ++++++++++ core/slack-sweep-state.js | 63 ++++++++++++++ core/slack-sweep-state.test.ts | 80 +++++++++++++++++ .../adr/0016-persist-slack-sweep-watermark.md | 47 ++++++++++ web/server.mjs | 85 ++++++++++++++++++- 9 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 core/atomic-write.js create mode 100644 core/atomic-write.test.ts create mode 100644 core/slack-sweep-state.js create mode 100644 core/slack-sweep-state.test.ts create mode 100644 docs/adr/0016-persist-slack-sweep-watermark.md diff --git a/CLAUDE.md b/CLAUDE.md index 503f755..2903e6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,12 +65,14 @@ cdp-browser/ ├── preload.js # IPC bridge (contextBridge) ├── core/ # Backend-agnostic shared CommonJS modules (consumed by main.js + web/server.mjs) │ ├── notifications.js # Pure notification logic (dedup, cap, OS-toast gating, Slack workspace key: parseSlackContext/slackGroupKey) -│ ├── notifications-sidechain.js # Notification Side-Channel state machine (createNotificationCenter, DI); Slack-only cred extraction (xoxc+d-cookie, carries enterpriseId for Grid grouping t092) via onCreds dep + listCreds/getCreds/markCredsStale/setSelfUserId accessors (t069). Side-channel cdpCall has a timeout + rejects pending on close, and reconcile reaps a hung non-OPEN socket on a still-live target (t096) +│ ├── notifications-sidechain.js # Notification Side-Channel state machine (createNotificationCenter, DI); Slack-only cred extraction (xoxc+d-cookie, carries enterpriseId for Grid grouping t092) via onCreds dep + listCreds/getCreds/markCredsStale/setSelfUserId accessors (t069). markCredsStale re-extracts over the live socket (refreshCreds) so a rotated token self-heals; recordCreds fires onCredsStuck when the re-extract reads the same stale token so the server reloads the keeper-owned parked tab, never a pin (t099). Side-channel cdpCall has a timeout + rejects pending on close, and reconcile reaps a hung non-OPEN socket on a still-live target (t096) │ ├── remote-page-connector.js # Remote Page connect choreography (createRemotePageConnector, DI) + screencast rate ceiling (SCREENCAST_TARGET_FPS/EVERY_NTH_FRAME) │ ├── theme-emulation.js # Pure theme-sync logic (emulatedMediaParams) │ ├── cdp-endpoints.js # Pure /json URL builders │ ├── settings-store.js # Pure settings/pins/ui-state store (device-suffixed ui-state keys pass by prefix allowlist, t093) │ ├── line-splitter.js # Pure NDJSON frame reassembly for the streaming input channel +│ ├── atomic-write.js # Atomic write-temp-then-rename (crash-safe JSON persistence); shared by server + main (t099) +│ ├── slack-sweep-state.js # Persisted Slack sweep {watermark, seeded} — serialize/deserialize + DI debounced persister; restart resumes from watermark instead of re-seeding (t099, ADR-0016) │ ├── frame-throttle.js # Pure screencast rate throttle (createFrameThrottle, DI clock; fresh-frame-wins) │ ├── frame-ack-gate.js # Pure one-in-flight gate + watchdog for WS paint-ack backpressure (t056) │ ├── paint-ack-pacer.js # Pure adaptive paint-ack watchdog window: EWMA of observed paint latency sizes the stranded-paint timeout (floor 1s, cap 5s) so a slow device isn't tripped early (t096, P2) diff --git a/core/atomic-write.js b/core/atomic-write.js new file mode 100644 index 0000000..b43bc0a --- /dev/null +++ b/core/atomic-write.js @@ -0,0 +1,15 @@ +// Atomic file write: write to a temp sibling then rename (atomic on the same filesystem), so +// a crash mid-write can't truncate/reset the target JSON (settings, push subs, notifications, +// Slack registry + sweep state). Shared by web/server.mjs and main.js. `fs` is injectable for +// tests. A leftover `.tmp` from a prior crash is simply overwritten on the next write. +const fs = require("fs") + +function atomicWriteFileSync(path, data, deps = {}) { + const writeFileSync = deps.writeFileSync || fs.writeFileSync + const renameSync = deps.renameSync || fs.renameSync + const tmp = `${path}.tmp` + writeFileSync(tmp, data) + renameSync(tmp, path) +} + +module.exports = { atomicWriteFileSync } diff --git a/core/atomic-write.test.ts b/core/atomic-write.test.ts new file mode 100644 index 0000000..c7638e4 --- /dev/null +++ b/core/atomic-write.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest" +// @ts-expect-error — CJS module, no types +import { atomicWriteFileSync } from "./atomic-write.js" + +describe("atomicWriteFileSync", () => { + it("writes to a temp sibling then renames onto the target", () => { + const calls: string[] = [] + const writeFileSync = vi.fn((p: string) => calls.push(`write:${p}`)) + const renameSync = vi.fn((from: string, to: string) => calls.push(`rename:${from}->${to}`)) + + atomicWriteFileSync("/data/settings.json", "{}", { writeFileSync, renameSync }) + + expect(writeFileSync).toHaveBeenCalledWith("/data/settings.json.tmp", "{}") + expect(renameSync).toHaveBeenCalledWith("/data/settings.json.tmp", "/data/settings.json") + // write must happen before rename + expect(calls).toEqual([ + "write:/data/settings.json.tmp", + "rename:/data/settings.json.tmp->/data/settings.json", + ]) + }) + + it("does not rename (leaving the original intact) when the write throws", () => { + const writeFileSync = vi.fn(() => { + throw new Error("disk full") + }) + const renameSync = vi.fn() + + expect(() => atomicWriteFileSync("/data/x.json", "{}", { writeFileSync, renameSync })).toThrow( + "disk full", + ) + expect(renameSync).not.toHaveBeenCalled() + }) +}) diff --git a/core/notifications-sidechain.js b/core/notifications-sidechain.js index 5f2ec59..b5cd39e 100644 --- a/core/notifications-sidechain.js +++ b/core/notifications-sidechain.js @@ -95,6 +95,9 @@ function createNotificationCenter(deps) { const persist = () => save(notifications) const sideChannels = new Map() // targetId -> ws + // Live cred-capable (Slack) sockets: targetId -> { cdpCall, target }. Lets markCredsStale + // re-run extraction over an already-open socket without waiting for a reconnect (t099). + const credSockets = new Map() // Slack credential records keyed by teamId (t069). Populated by side-channel extraction; // read by the server-side content sweep (t071). Each: { teamId, token, cookie, name, url, // enterpriseId, fresh, lastError, selfUserId? }. Creds are the user's own session secrets @@ -109,8 +112,17 @@ function createNotificationCenter(deps) { const WS_OPEN = WebSocketCtor && WebSocketCtor.OPEN != null ? WebSocketCtor.OPEN : 1 function recordCreds(team, cookie) { - const prev = credsByTeam.get(team.teamId) || {} - const rec = markFresh(prev, { + const prev = credsByTeam.get(team.teamId) + // If a re-extraction (after a 401) produced the SAME token that was already stale, the + // live tab's localConfig itself is stale — re-extract can't fix it. Signal the server to + // reload the keeper-owned parked tab so Slack regenerates a token (t099); keep the record + // stale (don't mark a known-bad token fresh). A changed token gets a fresh chance below. + if (prev && prev.fresh === false && prev.token === team.token) { + log(`[slack-creds] ${team.teamId} still stale after re-extract — signalling reload`) + if (deps.onCredsStuck) deps.onCredsStuck(team.teamId) + return + } + const rec = markFresh(prev || {}, { teamId: team.teamId, token: team.token, cookie, @@ -233,8 +245,10 @@ function createNotificationCenter(deps) { // document-start for future loads + the already-loaded document. cdp("Page.addScriptToEvaluateOnNewDocument", { source: sourceFor(adapter) }) cdp("Runtime.evaluate", { expression: sourceFor(adapter) }) - // Slack only: pull the workspace creds for the server-side sweep (ADR-0011). + // Slack only: pull the workspace creds for the server-side sweep (ADR-0011). Keep the + // live cdpCall handle so a later 401 can re-extract over this same socket (t099). if (adapter.extractCreds && deps.onCreds) { + credSockets.set(target.id, { cdpCall, target }) extractSlackCreds(cdpCall, target).catch(() => {}) } }) @@ -253,6 +267,7 @@ function createNotificationCenter(deps) { }) const drop = () => { if (sideChannels.get(target.id) === ws) sideChannels.delete(target.id) + credSockets.delete(target.id) // Free any in-flight awaitable call so a stalled socket can't leak its promise. for (const p of pending.values()) p.reject(new Error("side-channel closed")) pending.clear() @@ -287,6 +302,21 @@ function createNotificationCenter(deps) { } } + // Re-extract creds over any already-open Slack side-channel socket (t099). One live tab's + // localConfig_v2 + shared `d` cookie refresh EVERY workspace, so the first live socket wins. + // Called on a 401 (markCredsStale) so a rotated xoxc token self-heals within a sweep cycle + // without waiting for a reconnect. Best-effort; resolves false when no live socket exists. + async function refreshCreds() { + for (const [id, ws] of sideChannels) { + if (ws.readyState !== WS_OPEN) continue + const handle = credSockets.get(id) + if (!handle) continue + await extractSlackCreds(handle.cdpCall, handle.target) + return true + } + return false + } + // Attach to newly-seen adapter-matching Tab targets, drop vanished/changed ones. // `targets` is optional; when omitted, the injected `listTargets()` is called. async function reconcile(targets) { @@ -356,12 +386,17 @@ function createNotificationCenter(deps) { // Slack cred accessors (t069) — the server-side sweep (t071) reads these. listCreds: () => [...credsByTeam.values()], getCreds: (teamId) => credsByTeam.get(teamId) || null, - // Mark a workspace's creds stale (e.g. the sweep got a 401) so the health surface - // (t074) can flag it and the parked-tab keeper (t070) re-extracts. Keeps the last creds. + // Mark a workspace's creds stale (e.g. the sweep got a 401) so the health surface (t074) + // can flag it, then immediately re-extract over a live Slack socket so a rotated token + // self-heals within a sweep cycle (t099). Keeps the last creds until a fresh read replaces + // them. If the re-extract reads the same stale token, recordCreds fires deps.onCredsStuck. markCredsStale: (teamId, reason) => { const prev = credsByTeam.get(teamId) if (prev) credsByTeam.set(teamId, markStale(prev, reason)) + refreshCreds().catch(() => {}) }, + // Force a re-extraction over any live Slack socket (t099) — exposed for the server + tests. + refreshCreds, // The sweep caches the resolved self user id (for channel @-mention parity) here. setSelfUserId: (teamId, selfUserId) => { const prev = credsByTeam.get(teamId) diff --git a/core/notifications-sidechain.test.ts b/core/notifications-sidechain.test.ts index 1e2a5a9..4f656ef 100644 --- a/core/notifications-sidechain.test.ts +++ b/core/notifications-sidechain.test.ts @@ -446,6 +446,50 @@ describe("slack cred extraction (t069)", () => { center.setSelfUserId("T0EXAMPLE02", "U_ME") expect(center.getCreds("T0EXAMPLE02")?.selfUserId).toBe("U_ME") }) + + it("markCredsStale re-extracts over the live socket; a rotated token clears the stale state (t099)", async () => { + const onCreds = vi.fn() + const onCredsStuck = vi.fn() + const { center } = makeCenter({ onCreds, onCredsStuck, onSlackSignal: vi.fn() }) + await center.reconcile([slackTarget()]) + const ws = FakeWs.instances[0] + ws.open() + await answerCredExtraction(ws) + + center.markCredsStale("T0EXAMPLE02", "invalid_auth") // fires refreshCreds() + await Promise.resolve() + // Slack rotated the token in localConfig — the re-extract reads the fresh value. + const rotated = JSON.stringify({ + teams: { T0EXAMPLE02: { token: "xoxc-NEW", name: "Acme", url: "https://acme.slack.com/" } }, + }) + await answerCredExtraction(ws, { config: rotated }) + + expect(center.getCreds("T0EXAMPLE02")).toMatchObject({ token: "xoxc-NEW", fresh: true }) + expect(onCredsStuck).not.toHaveBeenCalled() + }) + + it("signals onCredsStuck when the re-extract reads the SAME stale token (t099)", async () => { + const onCreds = vi.fn() + const onCredsStuck = vi.fn() + const { center } = makeCenter({ onCreds, onCredsStuck, onSlackSignal: vi.fn() }) + await center.reconcile([slackTarget()]) + const ws = FakeWs.instances[0] + ws.open() + await answerCredExtraction(ws) + + center.markCredsStale("T0EXAMPLE02", "invalid_auth") + await Promise.resolve() + await answerCredExtraction(ws) // same LOCAL_CONFIG → same token, still stale + + expect(onCredsStuck).toHaveBeenCalledWith("T0EXAMPLE02") + // A known-bad token is not marked fresh — the health surface stays degraded. + expect(center.getCreds("T0EXAMPLE02")?.fresh).toBe(false) + }) + + it("refreshCreds resolves false when no live Slack socket exists", async () => { + const { center } = makeCenter({ onCreds: vi.fn() }) + expect(await center.refreshCreds()).toBe(false) + }) }) describe("slack hijack ↔ sweep handoff (t071)", () => { diff --git a/core/slack-sweep-state.js b/core/slack-sweep-state.js new file mode 100644 index 0000000..1882e28 --- /dev/null +++ b/core/slack-sweep-state.js @@ -0,0 +1,63 @@ +// Persisted Slack sweep state (t099, ADR-0016). The per-workspace read watermark + the set of +// seeded teams are held in memory by web/server.mjs; persisting them means a server restart +// RESUMES from the watermark (backfilling the downtime gap through the sweep's history fetch) +// instead of re-seeding from `latest`, which silently drops every message that arrived while +// the server was down. Non-secret metadata only (channel ids + message timestamps). + +// In-memory { watermark, seeded:Set } -> on-disk { watermark, seeded:[] }. +function serialize(state) { + return { + watermark: (state && state.watermark) || {}, + seeded: [...((state && state.seeded) || [])], + } +} + +// On-disk (or missing/corrupt) -> in-memory. Defaults to empty so the first-ever boot (no +// file) seeds every team from `latest` (no cold-start spam), and a corrupt file never throws. +function deserialize(raw) { + const obj = raw && typeof raw === "object" ? raw : {} + return { + watermark: obj.watermark && typeof obj.watermark === "object" ? obj.watermark : {}, + seeded: new Set(Array.isArray(obj.seeded) ? obj.seeded : []), + } +} + +// Debounced persister — coalesces a burst of sweep completions into one trailing write, so a +// busy multi-workspace sweep doesn't churn the disk. `getState()` returns the live in-memory +// state (read at flush time, so the latest wins); `read`/`write` are the (atomic) IO; the +// timer is injectable for tests. `flushSync()` writes now and cancels any pending flush — the +// SIGTERM/SIGINT path calls it so a graceful shutdown never loses the last watermark. +function createSweepStatePersister({ + read, + write, + getState, + setTimer = setTimeout, + clearTimer = clearTimeout, + debounceMs = 2000, +}) { + let timer = null + + function scheduleFlush() { + if (timer) return // already pending — trailing edge writes the latest state + timer = setTimer(() => { + timer = null + write(serialize(getState())) + }, debounceMs) + } + + function flushSync() { + if (timer) { + clearTimer(timer) + timer = null + } + write(serialize(getState())) + } + + function load() { + return deserialize(read()) + } + + return { load, scheduleFlush, flushSync } +} + +module.exports = { serialize, deserialize, createSweepStatePersister } diff --git a/core/slack-sweep-state.test.ts b/core/slack-sweep-state.test.ts new file mode 100644 index 0000000..6db1df6 --- /dev/null +++ b/core/slack-sweep-state.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest" +// @ts-expect-error — CJS module, no types +import { createSweepStatePersister, deserialize, serialize } from "./slack-sweep-state.js" + +describe("serialize / deserialize", () => { + it("round-trips watermark + seeded (Set <-> array)", () => { + const state = { watermark: { T1: { C1: "111.0" } }, seeded: new Set(["T1", "T2"]) } + + const raw = serialize(state) + expect(raw).toEqual({ watermark: { T1: { C1: "111.0" } }, seeded: ["T1", "T2"] }) + + const back = deserialize(raw) + expect(back.watermark).toEqual({ T1: { C1: "111.0" } }) + expect(back.seeded).toBeInstanceOf(Set) + expect([...back.seeded]).toEqual(["T1", "T2"]) + }) + + it("deserialize defaults a missing / corrupt file to empty state", () => { + expect(deserialize(null)).toEqual({ watermark: {}, seeded: new Set() }) + expect(deserialize("garbage")).toEqual({ watermark: {}, seeded: new Set() }) + expect(deserialize({ watermark: 5, seeded: 7 })).toEqual({ watermark: {}, seeded: new Set() }) + }) +}) + +describe("createSweepStatePersister", () => { + function harness() { + let fire: (() => void) | null = null + const write = vi.fn() + const state = { watermark: {} as Record, seeded: new Set() } + const persister = createSweepStatePersister({ + read: () => null, + write, + getState: () => state, + setTimer: (cb: () => void) => { + fire = cb + return 1 as unknown + }, + clearTimer: vi.fn(), + debounceMs: 2000, + }) + return { persister, write, state, fireTimer: () => fire?.() } + } + + it("coalesces a burst of scheduleFlush into one trailing write of the latest state", () => { + const { persister, write, state, fireTimer } = harness() + + persister.scheduleFlush() + persister.scheduleFlush() + persister.scheduleFlush() + expect(write).not.toHaveBeenCalled() // debounced + + state.watermark = { T1: { C1: "222.0" } } // mutate after scheduling + fireTimer() + + expect(write).toHaveBeenCalledTimes(1) + expect(write).toHaveBeenCalledWith({ watermark: { T1: { C1: "222.0" } }, seeded: [] }) + }) + + it("flushSync writes immediately", () => { + const { persister, write, state } = harness() + state.seeded.add("T9") + + persister.flushSync() + + expect(write).toHaveBeenCalledWith({ watermark: {}, seeded: ["T9"] }) + }) + + it("load returns the deserialized read()", () => { + const write = vi.fn() + const persister = createSweepStatePersister({ + read: () => ({ watermark: { T1: { C1: "5.0" } }, seeded: ["T1"] }), + write, + getState: () => ({ watermark: {}, seeded: new Set() }), + }) + + const loaded = persister.load() + expect(loaded.watermark).toEqual({ T1: { C1: "5.0" } }) + expect([...loaded.seeded]).toEqual(["T1"]) + }) +}) diff --git a/docs/adr/0016-persist-slack-sweep-watermark.md b/docs/adr/0016-persist-slack-sweep-watermark.md new file mode 100644 index 0000000..1f3405a --- /dev/null +++ b/docs/adr/0016-persist-slack-sweep-watermark.md @@ -0,0 +1,47 @@ +# 0016 — Persist the Slack sweep watermark across restarts + +**Date:** 2026-07-07 +**Status:** Accepted +**Deciders:** t099 (C2) + +## Context + +The Slack Content Sweep (ADR-0011) is the authoritative delivery path for Slack notifications on the web build. Its per-workspace read state — the `watermark` (`teamId → { channelId: lastSeenTs }`) and the `seeded` set (teams whose baseline is established) — lived only in a module-level object in `web/server.mjs`. + +On a fresh process the first sweep for a team takes the **seed** branch: it sets the watermark from the workspace's current `latest`/now and emits nothing (so a cold start doesn't spam the backlog as "new"). That seed branch is correct for a genuinely-new workspace, but because the state was memory-only it also ran on **every restart** — so every deploy, crash, or container recycle re-seeded from "now" and silently dropped any message that arrived during the downtime window. For a push-notification daily driver (the web PWA, priority surface) that is periodic, invisible message loss — the exact failure the sweep exists to prevent. A code comment even claimed a cold start was "a re-fetch (not re-notify)", which was false. + +## Decision + +**Persist `{ watermark, seeded }` to disk and resume from it on boot.** + +- A new non-secret file `slack-sweep-state.json` (channel ids + message timestamps only — no creds) holds the serialized state, next to the other server JSON files. Env-overridable via `SLACK_SWEEP_STATE_PATH`. +- On boot the server loads it: a team with a persisted watermark is already `seeded`, so the next sweep takes the normal **fetch-since-watermark** branch and backfills the downtime gap; the store's stable `slack:{groupId}:{channel}:{ts}` id-dedup makes re-fetching already-ingested messages a no-op, so only genuinely-missed messages notify. Only a team with **no** persisted state seeds from `latest`. +- Writes go through a **debounced (~2s trailing)** persister that coalesces a busy multi-workspace sweep into one write, using the shared **atomic** write-temp-then-rename helper (`core/atomic-write.js`) so a crash mid-write can't corrupt the file. +- A **SIGTERM/SIGINT** handler flushes synchronously before exit, so a graceful redeploy never loses the last ~2s of read progress. (This also closes the "no server shutdown hook" gap the review found.) + +The pure serialize/deserialize + the DI'd debounced persister live in `core/slack-sweep-state.js` (unit-tested); `web/server.mjs` wires the IO and hooks `scheduleFlush()` into the sweep's `setWatermark`/`markSeeded`. + +## Consequences + +### Positive + +- A restart/redeploy no longer opens a silent notification blind spot: the sweep resumes from the watermark and backfills the gap. +- The seed-once-per-team semantics are now actually true (seed on first-ever sight, resume thereafter), matching what ADR-0011 always claimed. +- The atomic write + shutdown flush harden persistence generally (the helper is reused by C3 for the other JSON files). + +### Negative + +- After a **long** outage (hours/days) the resume fetches a large history window, which can produce a burst of older notifications on recovery. Accepted: delivering delayed messages is the goal, and the notification store cap bounds the burst. A short redeploy (the common case) backfills only seconds of gap. +- One more small file on disk. It is non-secret (no tokens/cookies), so it carries no new leak surface beyond the message metadata already in `web-notifications.json`. + +## Alternatives + +- **Seed from `last_read` instead of `latest`** to backfill still-unread pre-watch messages into the inbox. Rejected for this task: that is a different feature (an "unread catch-up view"), changes first-ever-boot behavior, and risks a large cold-start burst. Resume-from-watermark solves the restart-loss bug without it. +- **Fold the state into `slack-workspaces.json`.** Rejected: that file is a low-churn registry written on workspace discovery; the watermark advances on every sweep, so a separate file with its own debounced write cadence keeps the two write patterns from interfering. +- **Leave it memory-only and accept the loss.** Rejected: it is a P1 silent-notification-loss bug on the priority surface. + +## Links + +- **ADR-0011:** Slack Content Sweep guaranteed delivery — this makes its cold-start claim true. +- **Task t099 (C2):** capture continuity. +- **Modules:** `core/slack-sweep-state.js`, `core/atomic-write.js`. diff --git a/web/server.mjs b/web/server.mjs index a81d06f..c53c87a 100644 --- a/web/server.mjs +++ b/web/server.mjs @@ -14,6 +14,7 @@ import { dirname, extname, join, normalize } from "node:path" import { fileURLToPath } from "node:url" import webpush from "web-push" import WebSocket, { WebSocketServer } from "ws" +import { atomicWriteFileSync } from "../core/atomic-write.js" import endpoints from "../core/cdp-endpoints.js" import { deriveKey, open, seal } from "../core/crypto-envelope.js" import { createAckGate } from "../core/frame-ack-gate.js" @@ -30,6 +31,7 @@ import { createSettingsStore } from "../core/settings-store.js" import { createSlackApi } from "../core/slack-api.js" import { buildSlackGroups } from "../core/slack-creds.js" import { createSlackSweeper } from "../core/slack-sweep-runner.js" +import { createSweepStatePersister } from "../core/slack-sweep-state.js" import { liveTeamIds as slackLiveTeamIds, planParkedTabs as slackPlanParkedTabs, @@ -48,6 +50,10 @@ const NOTIFS_PATH = process.env.NOTIFS_PATH || join(HERE, "..", "web-notificatio // the shared d cookie + all-team localConfig re-extract from any live Slack tab. const SLACK_WORKSPACES_PATH = process.env.SLACK_WORKSPACES_PATH || join(HERE, "..", "slack-workspaces.json") +// Slack sweep read state (t099, ADR-0016) — non-secret {watermark, seeded}. Persisted so a +// restart RESUMES from the watermark (backfilling the downtime gap) instead of re-seeding. +const SLACK_SWEEP_STATE_PATH = + process.env.SLACK_SWEEP_STATE_PATH || join(HERE, "..", "slack-sweep-state.json") // Browser tab title for the web build, set at deploy time. Electron keeps the // title baked into index.html (it loads the file directly, not via this server). const APP_TITLE = process.env.APP_TITLE || "CDP Browser" @@ -566,6 +572,10 @@ const notificationCenter = createNotificationCenter({ const rec = notificationCenter.getCreds(teamId) if (rec && rec.fresh !== false) sweepScheduler.request(teamId, rec) }, + // A workspace still stale after re-extraction (t099): the live tab's localConfig token is + // itself stale, so re-extract can't fix it. Close the keeper-owned tab (never a pin) so the + // keeper recreates it with a fresh token on the next cycle. + onCredsStuck: (teamId) => reloadStuckSlackTab(teamId), onEntry: (entry) => { // Verbose prod log (greppable [notif]) — proves the entry's keying: a Grid workspace's // entries carry the merged `slack:{groupId}` groupKey (t092) while keeping a concrete team. @@ -657,6 +667,38 @@ async function keepSlackTabsAlive(targets) { if (changed) saveSlackRegistry() } +// Escalation when a Slack workspace stays stale after re-extraction (t099): close its live +// tab so the keeper recreates it with a fresh token — but ONLY when the tab is keeper-owned. +// A pinned workspace is owned by its pin (t098): closing it would disrupt the user, so we +// leave it and let the health surface degrade. A per-team cooldown prevents a reload loop. +const slackStuckReloadAt = {} // teamId → last stuck-reload timestamp +const STUCK_RELOAD_COOLDOWN_MS = 5 * 60 * 1000 +async function reloadStuckSlackTab(teamId) { + if (Date.now() - (slackStuckReloadAt[teamId] || 0) < STUCK_RELOAD_COOLDOWN_MS) return + const pinned = settings.getPins().some((p) => slackTeamIdOf(p.url || "") === teamId) + if (pinned) { + console.log(`[web] slack ${teamId} stuck stale but pinned — leaving it (health degrades)`) + return + } + let targets + try { + targets = await fetchJson(endpoints.list(host(), port())) + } catch { + return + } + const tab = (Array.isArray(targets) ? targets : []).find( + (t) => t.type === "page" && slackTeamIdOf(t.url || "") === teamId, + ) + if (!tab) return + slackStuckReloadAt[teamId] = Date.now() + try { + await fetchJson(endpoints.close(host(), port(), tab.id)) + console.log(`[web] slack ${teamId} stuck stale — closed keeper tab for a fresh token`) + } catch (e) { + console.error(`[web] slack stuck-reload close failed for ${teamId}:`, e.message) + } +} + // One combined reconcile cycle: notification side-channels + the Slack parked-tab keeper. // A single /json fetch feeds both so we don't double-poll the remote browser. async function reconcileCycle() { @@ -693,15 +735,39 @@ const nameCacheSet = (bucket, team, id, name) => { if (!slackSweepState[bucket][team]) slackSweepState[bucket][team] = {} slackSweepState[bucket][team][id] = name } +// Persist {watermark, seeded} across restarts (t099, ADR-0016). Debounced + atomic write; +// getState reads the live sweep state at flush time so the latest watermark wins. +const sweepStatePersister = createSweepStatePersister({ + read: () => loadJson(SLACK_SWEEP_STATE_PATH, null), + write: (data) => { + try { + atomicWriteFileSync(SLACK_SWEEP_STATE_PATH, JSON.stringify(data)) + } catch (e) { + console.error("[web] sweep-state persist failed:", e.message) + } + }, + getState: () => slackSweepState, +}) +// Resume on boot: previously-seeded teams fetch since their watermark (backfilling the +// downtime gap) instead of re-seeding from `latest` and silently dropping downtime messages. +{ + const restored = sweepStatePersister.load() + slackSweepState.watermark = restored.watermark + for (const t of restored.seeded) slackSweepState.seeded.add(t) +} const slackSweeper = createSlackSweeper({ listCreds: () => notificationCenter.listCreds(), makeApi: (cred) => createSlackApi({ token: cred.token, cookie: cred.cookie }), getWatermark: (t) => slackSweepState.watermark[t] || {}, setWatermark: (t, w) => { slackSweepState.watermark[t] = w + sweepStatePersister.scheduleFlush() }, isSeeded: (t) => slackSweepState.seeded.has(t), - markSeeded: (t) => slackSweepState.seeded.add(t), + markSeeded: (t) => { + slackSweepState.seeded.add(t) + sweepStatePersister.scheduleFlush() + }, // Channel Exclude list (t072): the excluded channel ids for this workspace, read live // from ui-state (`slackExcludes: {team,channelId,label}[]`) so a mute applies next sweep. // The runner passes the merged groupId (t092); excludes are stored keyed by that same @@ -1200,6 +1266,23 @@ process.on("unhandledRejection", (reason) => { console.error("[err] unhandledRejection:", reason?.stack || reason) }) +// Graceful shutdown (t099): flush the debounced Slack sweep watermark so a redeploy never +// loses the last ~2s of read progress, then exit. Closes the "no shutdown hook" gap. +let shuttingDown = false +const gracefulShutdown = (sig) => { + if (shuttingDown) return + shuttingDown = true + try { + sweepStatePersister.flushSync() + } catch (e) { + console.error("[web] shutdown flush failed:", e.message) + } + console.log(`[web] ${sig} — flushed sweep state, exiting`) + process.exit(0) +} +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")) +process.on("SIGINT", () => gracefulShutdown("SIGINT")) + server.listen(PORT, "0.0.0.0", () => console.log( `[web] v${APP_VERSION} ${GIT_SHA} http://0.0.0.0:${PORT} -> cdp ${host()}:${port()}`, From b52e5d0c30e21eb2202c978abed5e00374b50e34 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 15:08:14 +0700 Subject: [PATCH 3/6] fix(server): ws heartbeat + backpressure, atomic writes, body validation --- CLAUDE.md | 2 + core/request-guards.js | 25 +++++++++++ core/request-guards.test.ts | 35 +++++++++++++++ core/ws-backpressure.js | 18 ++++++++ core/ws-backpressure.test.ts | 33 ++++++++++++++ web/server.mjs | 84 +++++++++++++++++++++++++++++++----- 6 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 core/request-guards.js create mode 100644 core/request-guards.test.ts create mode 100644 core/ws-backpressure.js create mode 100644 core/ws-backpressure.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2903e6a..01d44a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,8 @@ cdp-browser/ │ ├── settings-store.js # Pure settings/pins/ui-state store (device-suffixed ui-state keys pass by prefix allowlist, t093) │ ├── line-splitter.js # Pure NDJSON frame reassembly for the streaming input channel │ ├── atomic-write.js # Atomic write-temp-then-rename (crash-safe JSON persistence); shared by server + main (t099) +│ ├── ws-backpressure.js # Pure WS fan-out predicates: shouldSkipClient (over-buffer drop) + isClientDead (heartbeat reap) — half-open sleeping client can't buffer frames unbounded (t099) +│ ├── request-guards.js # Pure mutation-payload shape guards: isValidConfig/isValidPinsArray — a malformed body can't wipe config/pins (t099) │ ├── slack-sweep-state.js # Persisted Slack sweep {watermark, seeded} — serialize/deserialize + DI debounced persister; restart resumes from watermark instead of re-seeding (t099, ADR-0016) │ ├── frame-throttle.js # Pure screencast rate throttle (createFrameThrottle, DI clock; fresh-frame-wins) │ ├── frame-ack-gate.js # Pure one-in-flight gate + watchdog for WS paint-ack backpressure (t056) diff --git a/core/request-guards.js b/core/request-guards.js new file mode 100644 index 0000000..b4c5a3a --- /dev/null +++ b/core/request-guards.js @@ -0,0 +1,25 @@ +// Pure request-payload shape guards (t099). A malformed/undecryptable body is rejected upstream +// (400); these guard the SHAPE of the dangerous mutation payloads so a syntactically-valid but +// wrong body (e.g. an empty {} from a masked decrypt) can't persist and wipe config/pins. + +function isValidConfig(v) { + return ( + !!v && + typeof v === "object" && + typeof v.host === "string" && + v.host.trim().length > 0 && + v.port != null && + v.port !== "" && + Number.isFinite(Number(v.port)) + ) +} + +function isPinObject(v) { + return !!v && typeof v === "object" && typeof v.id === "string" && v.id.length > 0 +} + +function isValidPinsArray(v) { + return Array.isArray(v) && v.every(isPinObject) +} + +module.exports = { isValidConfig, isPinObject, isValidPinsArray } diff --git a/core/request-guards.test.ts b/core/request-guards.test.ts new file mode 100644 index 0000000..b8af4a0 --- /dev/null +++ b/core/request-guards.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" +// @ts-expect-error — CJS module, no types +import { isValidConfig, isValidPinsArray } from "./request-guards.js" + +describe("isValidConfig", () => { + it("accepts a well-shaped host/port config", () => { + expect(isValidConfig({ host: "10.0.0.1", port: 9222 })).toBe(true) + expect(isValidConfig({ host: "example.test", port: "9222" })).toBe(true) + }) + + it("rejects the empty / masked-body object that would wipe the CDP address", () => { + expect(isValidConfig({})).toBe(false) + }) + + it("rejects a missing/blank host or non-numeric port", () => { + expect(isValidConfig({ host: "", port: 9222 })).toBe(false) + expect(isValidConfig({ host: "h", port: "nope" })).toBe(false) + expect(isValidConfig({ port: 9222 })).toBe(false) + expect(isValidConfig(null)).toBe(false) + }) +}) + +describe("isValidPinsArray", () => { + it("accepts an array of pin objects with string ids", () => { + expect(isValidPinsArray([{ id: "p1" }, { id: "p2", url: "https://x" }])).toBe(true) + expect(isValidPinsArray([])).toBe(true) + }) + + it("rejects non-arrays and arrays with malformed members", () => { + expect(isValidPinsArray({})).toBe(false) + expect(isValidPinsArray(null)).toBe(false) + expect(isValidPinsArray([{ id: "p1" }, { url: "no-id" }])).toBe(false) + expect(isValidPinsArray([{ id: 5 }])).toBe(false) + }) +}) diff --git a/core/ws-backpressure.js b/core/ws-backpressure.js new file mode 100644 index 0000000..e7b15fe --- /dev/null +++ b/core/ws-backpressure.js @@ -0,0 +1,18 @@ +// Pure predicates for the WS fan-out backpressure + liveness (t099). No sockets here — the +// caller reads `ws.bufferedAmount` and tracks pong timestamps, then applies these decisions. + +// Drop this frame for a client whose send buffer is already backed up past the cap (a +// suspended/slow iPad) — fresh-frame-wins, never accrete a backlog into a half-open socket +// (which `ws.send` buffers without throwing). A non-positive cap disables skipping. +function shouldSkipClient(bufferedAmount, cap) { + return cap > 0 && bufferedAmount > cap +} + +// A client that produced no liveness signal (pong) within the deadline is dead and should be +// terminated + evicted — the only way a half-open socket (never throws on send) gets reaped. +// A missing lastSeenAt (never ponged) past the deadline counts as dead. +function isClientDead(lastSeenAt, now, deadlineMs) { + return now - (lastSeenAt || 0) > deadlineMs +} + +module.exports = { shouldSkipClient, isClientDead } diff --git a/core/ws-backpressure.test.ts b/core/ws-backpressure.test.ts new file mode 100644 index 0000000..b6d1171 --- /dev/null +++ b/core/ws-backpressure.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" +// @ts-expect-error — CJS module, no types +import { isClientDead, shouldSkipClient } from "./ws-backpressure.js" + +describe("shouldSkipClient", () => { + it("skips a client whose send buffer is over the cap (fresh-frame-wins)", () => { + expect(shouldSkipClient(9_000_000, 8_000_000)).toBe(true) + }) + + it("serves a client at or under the cap", () => { + expect(shouldSkipClient(8_000_000, 8_000_000)).toBe(false) + expect(shouldSkipClient(0, 8_000_000)).toBe(false) + }) + + it("disables skipping when the cap is non-positive", () => { + expect(shouldSkipClient(999_999_999, 0)).toBe(false) + expect(shouldSkipClient(999_999_999, -1)).toBe(false) + }) +}) + +describe("isClientDead", () => { + it("is dead when no liveness signal arrived within the deadline", () => { + expect(isClientDead(1000, 1000 + 61_000, 60_000)).toBe(true) + }) + + it("is alive when a signal arrived within the deadline", () => { + expect(isClientDead(1000, 1000 + 30_000, 60_000)).toBe(false) + }) + + it("treats a missing lastSeenAt as long-dead (evicts a never-ponged socket past the deadline)", () => { + expect(isClientDead(undefined, 100_000, 60_000)).toBe(true) + }) +}) diff --git a/web/server.mjs b/web/server.mjs index c53c87a..dd3b5e3 100644 --- a/web/server.mjs +++ b/web/server.mjs @@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process" import nodeCrypto from "node:crypto" -import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs" +import { createReadStream, existsSync, readFileSync } from "node:fs" import http from "node:http" import { dirname, extname, join, normalize } from "node:path" import { fileURLToPath } from "node:url" @@ -27,6 +27,7 @@ import { createPaintAckPacer } from "../core/paint-ack-pacer.js" import { pushSendOptions } from "../core/push-send-options.js" import { reconcileDeviceId } from "../core/push-subscriptions.js" import connector from "../core/remote-page-connector.js" +import { isValidConfig, isValidPinsArray } from "../core/request-guards.js" import { createSettingsStore } from "../core/settings-store.js" import { createSlackApi } from "../core/slack-api.js" import { buildSlackGroups } from "../core/slack-creds.js" @@ -39,6 +40,7 @@ import { upsertWorkspace as slackUpsertWorkspace, } from "../core/slack-workspaces.js" import { createSweepScheduler } from "../core/sweep-scheduler.js" +import { isClientDead, shouldSkipClient } from "../core/ws-backpressure.js" const HERE = dirname(fileURLToPath(import.meta.url)) const DIST = join(HERE, "..", "dist") @@ -130,7 +132,7 @@ const settings = createSettingsStore({ }), persist: (s) => { try { - writeFileSync(SETTINGS_PATH, JSON.stringify(s, null, 2)) + atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(s, null, 2)) } catch (e) { console.error("[web] settings persist failed:", e.message) } @@ -144,7 +146,7 @@ if (process.env.CDP_HOST) pushSubs = loadJson(SUBS_PATH, []) const savePushSubs = () => { try { - writeFileSync(SUBS_PATH, JSON.stringify(pushSubs, null, 2)) + atomicWriteFileSync(SUBS_PATH, JSON.stringify(pushSubs, null, 2)) } catch (e) { console.error("[web] savePushSubs failed:", e.message) } @@ -247,6 +249,13 @@ const port = () => settings.getConfig().port // ---- SSE fan-out ---------------------------------------------------------- const sseClients = new Set() const wsClients = new Set() // WebSocket subscribers — receive same events SSE clients do (t019) +// WS liveness + backpressure (t099). A half-open socket (suspended iPad) never throws on send, +// so without a heartbeat it would buffer frames unboundedly. Ping every interval; reap a client +// that hasn't ponged within the deadline; skip a frame for a client whose send buffer is over +// the cap (fresh-frame-wins) instead of accreting a backlog. +const WS_HEARTBEAT_INTERVAL_MS = 30_000 +const WS_PONG_DEADLINE_MS = 70_000 // ~2 missed heartbeats + margin +const WS_FRAME_BUFFER_CAP = 8 * 1024 * 1024 // ~a few frames; over this, drop the frame for that client // WS clients that announced ack-after-paint support (t056): they ack each Screencast // Frame only after painting it, so the server defers its own remote-ack and gates the // next frame on their paint-ack — at most one frame in flight on the link instead of an @@ -281,6 +290,9 @@ function broadcastFrameBinaryRaw(bytes, meta) { }, }) for (const ws of wsClients) { + // Skip a client whose send buffer is backed up (suspended/slow) — fresh-frame-wins; the + // heartbeat reaps a truly-dead one. Prevents a half-open socket buffering frames unbounded. + if (shouldSkipClient(ws.bufferedAmount, WS_FRAME_BUFFER_CAP)) continue try { ws.send(envelope) ws.send(bytes) @@ -550,7 +562,7 @@ const notificationCenter = createNotificationCenter({ load: () => loadJson(NOTIFS_PATH, []), save: (list) => { try { - writeFileSync(NOTIFS_PATH, JSON.stringify(list)) + atomicWriteFileSync(NOTIFS_PATH, JSON.stringify(list)) } catch (e) { console.error("[web] saveNotifs failed:", e.message) } @@ -619,7 +631,7 @@ let slackRegistry = loadJson(SLACK_WORKSPACES_PATH, {}) const slackCreatedAt = {} // teamId → last /json/new timestamp (create cooldown) const saveSlackRegistry = () => { try { - writeFileSync(SLACK_WORKSPACES_PATH, JSON.stringify(slackRegistry, null, 2)) + atomicWriteFileSync(SLACK_WORKSPACES_PATH, JSON.stringify(slackRegistry, null, 2)) } catch (e) { console.error("[web] saveSlackRegistry failed:", e.message) } @@ -865,8 +877,19 @@ function checkSlackHealthAlerts() { // Periodic backstop sweep (every 15s) — the hijack trigger + cred-extraction trigger carry // real-time delivery; this guarantees completeness even if the hijack never fires (tab // asleep, native-app routing) or a signal was missed. Health alerts piggyback the cycle. +// Single-flight guard (t099): a sweep that hits a 429 Retry-After sleeps past the 15s tick, so +// without this a second (and third) runOnce would stack and pile duplicate load into the exact +// rate-limit we're already backing off from. +let sweepBackstopInFlight = false setInterval(() => { - slackSweeper.runOnce().catch(() => {}) + if (sweepBackstopInFlight) return + sweepBackstopInFlight = true + slackSweeper + .runOnce() + .catch(() => {}) + .finally(() => { + sweepBackstopInFlight = false + }) checkSlackHealthAlerts() }, 15_000) @@ -887,7 +910,10 @@ const body = (req) => if (!b) return resolve({}) resolve(e2eKey ? open(b.trim(), e2eKey) : JSON.parse(b)) } catch { - resolve({}) + // A malformed / undecryptable body used to resolve `{}`, which then persisted and could + // wipe pins/config. Reject with a tagged error so the handler answers 400 and nothing + // is written (t099). An empty body still resolves `{}` above (valid for bodyless POSTs). + reject(Object.assign(new Error("malformed request body"), { badBody: true })) } }) }) @@ -1037,7 +1063,10 @@ const server = http.createServer(async (req, res) => { // config if (p === "/api/config" && !POST) return json(res, settings.getConfig()) if (p === "/api/config" && POST) { - settings.setConfig(await body(req)) + const cfg = await body(req) + // Shape-guard so a wrong/empty body can't wipe the CDP address (t099). + if (!isValidConfig(cfg)) return json(res, { error: "invalid config" }, 400) + settings.setConfig(cfg) return json(res, settings.getConfig()) } if (p === "/api/config/test" && POST) { @@ -1082,8 +1111,12 @@ const server = http.createServer(async (req, res) => { return json(res, settings.updatePin(id, patch)) } if (p === "/api/pins/remove" && POST) return json(res, settings.removePin((await body(req)).id)) - if (p === "/api/pins/reorder" && POST) - return json(res, settings.reorderPins((await body(req)).pins)) + if (p === "/api/pins/reorder" && POST) { + const { pins } = await body(req) + // Shape-guard so a wrong/empty body can't blow away the pin list (t099). + if (!isValidPinsArray(pins)) return json(res, { error: "invalid pins" }, 400) + return json(res, settings.reorderPins(pins)) + } // notifications if (p === "/api/notifications" && !POST) return json(res, notificationCenter.list()) // Slack capture health per workspace (t074) — attached/creds/sweep state for the Settings row. @@ -1156,6 +1189,9 @@ const server = http.createServer(async (req, res) => { return json(res, { ok: true }) } } catch (e) { + // A malformed/undecryptable body (t099) is a client error → 400, and no route ran, so + // nothing was persisted. Everything else stays a 500. + if (e && e.badBody) return json(res, { error: "malformed request body" }, 400) return json(res, { error: e.message }, 500) } @@ -1180,9 +1216,17 @@ server.on("upgrade", (req, socket, head) => { } wss.handleUpgrade(req, socket, head, (ws) => { wsClients.add(ws) + // Liveness for the heartbeat reaper (t099): stamp on connect, on protocol pong, and on any + // inbound message (the client's 20s app-ping doubles as a liveness signal through proxies + // that swallow protocol pings). + ws.__lastPongAt = Date.now() + ws.on("pong", () => { + ws.__lastPongAt = Date.now() + }) console.log(`[client] ws +1 (now ${wsClients.size})`) ws.send(JSON.stringify({ t: "ready" })) ws.on("message", async (raw) => { + ws.__lastPongAt = Date.now() const text = raw.toString() // Ping/pong is control traffic, never E2E-sealed (it carries only the client's own // monotonic stamp, no user content) — handle it before the envelope open so it works @@ -1261,6 +1305,26 @@ server.on("upgrade", (req, socket, head) => { }) }) +// Heartbeat reaper (t099): ping every client each interval and terminate + evict one that +// hasn't produced a liveness signal within the deadline — the only way a half-open socket +// (never throws on send) gets cleaned up, freeing its buffered memory and the paint-ack slot. +setInterval(() => { + for (const ws of wsClients) { + if (isClientDead(ws.__lastPongAt, Date.now(), WS_PONG_DEADLINE_MS)) { + try { + ws.terminate() + } catch {} + wsClients.delete(ws) + if (paintAckClients.delete(ws) && paintAckClients.size === 0) resetPaintAckGate() + console.log(`[client] ws reaped (dead >${WS_PONG_DEADLINE_MS}ms; now ${wsClients.size})`) + continue + } + try { + ws.ping() + } catch {} + } +}, WS_HEARTBEAT_INTERVAL_MS) + // Surface otherwise-silent async failures in prod logs (greppable [err]) for issue-detection. process.on("unhandledRejection", (reason) => { console.error("[err] unhandledRejection:", reason?.stack || reason) From 465fbeb725ef8847430c49cebb843af46c863c24 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 15:23:08 +0700 Subject: [PATCH 4/6] fix(client): reconnect retry, downlink guard, wake resync, viewport tier --- src/app.tsx | 6 ++-- src/components/settings-dialog.tsx | 13 ++++---- src/components/viewport.tsx | 10 +++++- src/lib/CLAUDE.md | 4 ++- src/lib/cdp-web-transport.ts | 46 +++++++++++++++++++++++++++- src/lib/downlink-dispatcher.test.ts | 29 ++++++++++++++++++ src/lib/downlink-dispatcher.ts | 29 +++++++++++++++--- src/lib/quality-tier.test.ts | 30 ++++++++++++++++++ src/lib/quality-tier.ts | 19 ++++++++++++ src/lib/wake-resync.test.ts | 28 +++++++++++++++++ src/lib/wake-resync.ts | 11 +++++++ src/lib/web-reconnect-driver.test.ts | 30 ++++++++++++++++++ src/lib/web-reconnect-driver.ts | 27 ++++++++++------ src/lib/web-ws-channel.ts | 8 +++++ src/vite-env.d.ts | 6 ++++ 15 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 src/lib/quality-tier.test.ts create mode 100644 src/lib/wake-resync.test.ts create mode 100644 src/lib/wake-resync.ts diff --git a/src/app.tsx b/src/app.tsx index ed9da90..c2f51c5 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -461,8 +461,10 @@ export default function App() { // re-key any persisted Channel Excludes from the old per-team key to the merged one // (idempotent — a no-op once migrated). Standalone teams have no map entry → unchanged. if (caps.web) { - fetch("/api/notifications/health") - .then((r) => r.json()) + // Route through the bridge, not a raw fetch — under E2E the response is sealed, so a raw + // fetch would get opaque bytes and silently break Grid grouping + the exclude migration (t099). + window.cdp + .getNotificationHealth?.() .then((data) => { const groups = data && typeof data === "object" ? data.groups : null const map = groups && typeof groups === "object" ? (groups as Record) : {} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 9372f63..8f88e1f 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -376,12 +376,13 @@ export function SettingsDialog({ }) .catch(() => {}) } - fetch("/api/notifications/health") - .then((r) => r.json()) - // t092: the payload is now `{ rows, groups }` (rows merged per Enterprise Grid org; - // `groups` is the teamId → groupId map consumed by app.tsx). The card reads `rows`. - .then((data: { rows?: SlackHealthRow[] }) => - setSlackHealth(Array.isArray(data?.rows) ? data.rows : []), + // Through the bridge (not a raw fetch) so E2E responses open correctly (t099). + // t092: the payload is `{ rows, groups }` (rows merged per Enterprise Grid org; + // `groups` is the teamId → groupId map consumed by app.tsx). The card reads `rows`. + window.cdp + .getNotificationHealth?.() + .then((data) => + setSlackHealth(Array.isArray(data?.rows) ? (data.rows as SlackHealthRow[]) : []), ) .catch(() => setSlackHealth([])) } diff --git a/src/components/viewport.tsx b/src/components/viewport.tsx index c3a81bb..c6e3daf 100644 --- a/src/components/viewport.tsx +++ b/src/components/viewport.tsx @@ -20,6 +20,7 @@ import { } from "@/lib/echo-cursor" import { isOsReservedKey } from "@/lib/key-routing" import { perfFrame, perfMark } from "@/lib/perf-mark" +import { QUALITY_TIER_KEY, tierParams } from "@/lib/quality-tier" import type { RemotePage } from "@/lib/remote-page" import { createTouchGesture, type GestureEvent, LONGPRESS_MS } from "@/lib/touch-gesture" import { drawFrame, type Size, toRemoteCoords } from "@/lib/viewport-transform" @@ -272,9 +273,16 @@ export function Viewport({ const dpr = Math.min(window.devicePixelRatio, 2) const w = Math.min(Math.floor(vp.clientWidth * dpr), 1920) const h = Math.min(Math.floor(vp.clientHeight * dpr), 1080) + // Preserve the user's quality tier on reissue (t099): hardcoding quality:80 + omitting + // everyNthFrame used to silently reset the tier and the t054 rate ceiling on every resize. + // Read the live tier and apply its params (params mirror core/quality-tier.js). + const { jpegQuality, everyNthFrame } = tierParams( + typeof localStorage !== "undefined" ? localStorage.getItem(QUALITY_TIER_KEY) : null, + ) window.cdp.send("Page.startScreencast", { format: "jpeg", - quality: 80, + quality: jpegQuality, + everyNthFrame, maxWidth: w, maxHeight: h, }) diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index b6afbf5..5043e64 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -68,9 +68,11 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`reconnect-backoff.ts`** — Pure backoff schedule for auto-reconnect on a real Remote Page drop (t040). `nextBackoff(state, outcome, cfg)` is a tiny state machine over an attempt counter: a `"drop"` grows the delay exponentially (`baseMs × factorⁿ`) clamped at `capMs`; a `"success"` resets to the base; once `maxAttempts` is spent the verdict is `giveUp` (terminal "Disconnected"). I/O-free — no timers/sockets/DOM; the caller owns the timer that waits `delayMs` and the `connect` it then fires. The effectful loop is `createReconnectDriver` in `cdp-web-transport.ts` (web path), which hangs off the Downlink's real-drop signal and re-invokes the REST `connect` through the server-side `connectId` race-guard; `t041` (WS re-climb) and `t042` (the driver's `reconnectNow()` manual-tap verb) compose with the same schedule — one backoff counter, never a parallel loop. Tested by `reconnect-backoff.test.ts`. +**`wake-resync.ts`** — Pure wake-resync decision (t099). `shouldResyncOnWake({ visible, wsUp, sawSignalDuringProbe })` → boolean: on foreground the transport pings and watches for any server message during a short probe window; a socket believed up but silent is half-open (an iOS PWA suspended through a missed `disconnected`), so force a reconnect — killing the "frozen frame labelled Connected" bug. The effectful probe (ping + timer + WS teardown/reopen + `reconnectNow`) lives in `cdp-web-transport.ts`'s visibilitychange handler; the WS channel exposes `pingNow()` + an `onActivity` hook feeding the transport's `lastServerActivityTs`. Tested by `wake-resync.test.ts`. + **`caps.ts`** — Build capabilities, the single source of truth for "what can this build do." `WebCaps` is `{ web, localTabs, extensions }`; `getCaps(read?)` returns the injected/`window.webCaps` caps (web: the restricted `DEFAULT_CAPS`, `localTabs`/`extensions` off) or the implicit Electron full-capability default when `window.webCaps` is absent. `read` is injectable for tests; the only DOM coupling is the thin default reader. Consumed by `cdp-web-transport.ts` (which re-exports it for back-compat), `sidebar.tsx`, `settings-dialog.tsx`, `app.tsx`, and the `useLocalTabs` gate hook. See `docs/conventions/feature-gates.md`. -**`quality-tier.ts`** — Renderer-facing half of the Sharp/Balanced/Snappy quality-latency tier (t055, web only). Owns the `QualityTier` id type, the `QUALITY_TIERS` option list (id/label/tip) the Settings picker renders, the `QUALITY_TIER_KEY` localStorage key, and `parseTier(raw)` (garbage/null/wrong-case → `DEFAULT_TIER`). It does **not** own the screencast params — `{ jpegQuality, everyNthFrame }` live in `core/quality-tier.js` (the single owner, read by `core/remote-page-connector.js` + `main.js`; ADR-0008), because the renderer never applies them, the server does. Mirrors how `transport-selector.ts` owns the `InputTransportMode` ids for the t019 picker. Pure. +**`quality-tier.ts`** — Renderer-facing half of the Sharp/Balanced/Snappy quality-latency tier (t055, web only). Owns the `QualityTier` id type, the `QUALITY_TIERS` option list (id/label/tip) the Settings picker renders, the `QUALITY_TIER_KEY` localStorage key, and `parseTier(raw)` (garbage/null/wrong-case → `DEFAULT_TIER`). The server owns the screencast params on the connect path (`core/quality-tier.js`, ADR-0008). One renderer exception: `tierParams(tier)` mirrors those `{ jpegQuality, everyNthFrame }` numbers **only** for the resize reissue in `viewport.tsx` (t099) — a resize re-issues `Page.startScreencast` from the renderer, and it must carry the user's tier (quality + the t054 `everyNthFrame` rate ceiling) instead of resetting to a hardcoded default. `quality-tier.test.ts` asserts `tierParams` matches core exactly. Mirrors how `transport-selector.ts` owns the `InputTransportMode` ids for the t019 picker. Pure. **`echo-cursor.ts`** — Pure model for the viewport's optimistic local-input echo overlay (t052). `createEchoCursor()` is a state machine over `{ point, pressing }`; `down(pt)` records the press position + sets pressing, `move(pt)` updates position, `up()` clears pressing. `pointForEvent(e, rect, dpr)` maps a `PointerEvent` to canvas-relative coords (DPR-unscaled). No React, no DOM; `viewport.tsx` holds the instance, calls these on pointer events, and renders the overlay from the snapshot. Tested by `echo-cursor.test.ts`. diff --git a/src/lib/cdp-web-transport.ts b/src/lib/cdp-web-transport.ts index c7bd8b4..884f2eb 100644 --- a/src/lib/cdp-web-transport.ts +++ b/src/lib/cdp-web-transport.ts @@ -30,6 +30,7 @@ import { shouldReconnect, } from "./transport-selector" import { type AdvisedMode, createUplinkRouter, type Uplink } from "./uplink-router" +import { shouldResyncOnWake } from "./wake-resync" import { createInputChannel } from "./web-input-channel" import { createReconnectDriver, RECONNECT_CONFIG } from "./web-reconnect-driver" import { createWsChannel } from "./web-ws-channel" @@ -243,6 +244,10 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge }) let wsReady = false let ws: ReturnType | null = null + // Last time ANY server message arrived on the WS — the wake-resync probe (t099) watches it + // to distinguish a live socket from a half-open one after the PWA returns from suspend. + let lastServerActivityTs = 0 + const WAKE_PROBE_MS = 1500 // probe window after foreground before declaring the socket dead // Visible-tab WS re-climb cadence (t041): reuses the t040 backoff schedule so re-attempts // are spaced on the same curve (no second competing counter), resetting when WS heals. const reclimbSchedule = createWsReclimbSchedule(RECONNECT_CONFIG) @@ -290,7 +295,14 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge pumpDownlink(kind, JSON.parse(data)) return } - sseChain = sseChain.then(async () => pumpDownlink(kind, await crypto.openText(data))) + // Isolate each link (t099): a rejected decode used to leave `sseChain` permanently rejected, + // so every subsequent message's `.then` short-circuited — the whole downlink died until a + // reload. Catching per-link keeps the chain resolved so one bad frame can't poison it. + sseChain = sseChain + .then(async () => pumpDownlink(kind, await crypto.openText(data))) + .catch((e) => { + console.error("[downlink] decode/dispatch failed (isolated):", e) + }) } function attachSseListeners(src: EventSource) { src.addEventListener("cdp", (e) => { @@ -399,6 +411,9 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge if (!deps.WebSocket) return if (ws) return // already attempting / open ws = createWsChannel(deps, crypto, { + onActivity: () => { + lastServerActivityTs = Date.now() + }, onFrameBinary: (cdpMsg) => { // The WS binary-frame path: a forged Page.screencastFrame carrying the JPEG Blob. // Routes through the one Downlink like every other CDP event — the viewport reads @@ -685,6 +700,32 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge const probe = selector.onFocus() if (probe === "ws" && !wsReady && !ws && shouldOpenWs(wantMode)) openWs() armReclimb() // foregrounded — heal the WS path if it's down + + // Wake-resync (t099): the re-climb above only heals a WS we KNOW is down. A socket that + // went half-open while the PWA was suspended still reports ready, so the client sits on a + // frozen frame labelled "Connected". Ping now and, after a short window, if no server + // message arrived, treat the socket as dead — tear it down + reconnect the Remote Page. + if (wsReady && ws && deps.setTimer) { + const before = lastServerActivityTs + ws.pingNow() + deps.setTimer(() => { + const sawSignal = lastServerActivityTs !== before + if ( + !shouldResyncOnWake({ + visible: document.visibilityState === "visible", + wsUp: wsReady, + sawSignalDuringProbe: sawSignal, + }) + ) + return + ws?.close() // suppresses the channel onClose — replicate the teardown it would do + ws = null + wsReady = false + reopenSse() // bridge events over SSE until the fresh WS climbs back + openWs() // build a fresh WS socket + reconnect.reconnectNow() // and re-establish the Remote Page session (fresh frames) + }, WAKE_PROBE_MS) + } }) } @@ -840,6 +881,9 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge removePin: (id) => rest.postJson("/api/pins/remove", { id }), reorderPins: (pins) => rest.postJson("/api/pins/reorder", { pins }), getNotifications: () => rest.getJson("/api/notifications"), + // Slack capture health + the Grid teamId→groupId map. Through the REST bridge (not a raw + // fetch) so it opens the E2E envelope like every other /api call (t099). + getNotificationHealth: () => rest.getJson("/api/notifications/health"), markNotificationRead: (id) => rest.postJson("/api/notifications/mark-read", { id }), markNotificationUnread: (id) => rest.postJson("/api/notifications/mark-unread", { id }), markNotificationsRead: () => rest.postJson("/api/notifications/mark-all-read"), diff --git a/src/lib/downlink-dispatcher.test.ts b/src/lib/downlink-dispatcher.test.ts index 713e1ce..b6b0436 100644 --- a/src/lib/downlink-dispatcher.test.ts +++ b/src/lib/downlink-dispatcher.test.ts @@ -101,6 +101,35 @@ describe("createDownlinkDispatcher", () => { d.dispatch("cdp", { method: "X" }) expect(seen).toEqual(["b"]) }) + + it("isolates a throwing event listener so the others still receive (t099)", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}) + const d = createDownlinkDispatcher({ toast: () => {} }) + const seen: string[] = [] + d.onEvent(() => { + throw new Error("boom") + }) + d.onEvent(() => seen.push("b")) + + expect(() => d.dispatch("cdp", { method: "X" })).not.toThrow() + expect(seen).toEqual(["b"]) + err.mockRestore() + }) + + it("isolates a throwing toast so the notification still reaches listeners (t099)", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}) + const seen: unknown[] = [] + const d = createDownlinkDispatcher({ + toast: () => { + throw new Error("Notification not allowed") // iOS/Android page-context throw + }, + }) + d.onNotification((e) => seen.push(e)) + + expect(() => d.dispatch("notification", { id: "n1" })).not.toThrow() + expect(seen).toEqual([{ id: "n1" }]) + err.mockRestore() + }) }) // --- Downlink seam: one live source, decode-pump, close -------------------------------- diff --git a/src/lib/downlink-dispatcher.ts b/src/lib/downlink-dispatcher.ts index ae4e867..22a8f79 100644 --- a/src/lib/downlink-dispatcher.ts +++ b/src/lib/downlink-dispatcher.ts @@ -66,6 +66,19 @@ function register(list: T[], cb: T): () => void { } } +// Fan out to every listener even if one throws (t099): an unguarded loop would stop at the +// first throw AND propagate up to the source's message handler, poisoning the whole downlink +// (frozen session until reload). Each listener is isolated; a throw is logged, never fatal. +function fanOut(list: ((arg: T) => void)[], arg: T): void { + for (const cb of list) { + try { + cb(arg) + } catch (e) { + console.error("[downlink] listener threw (isolated):", e) + } + } +} + export function createDownlinkDispatcher(deps: DispatcherDeps): Dispatcher { const listeners = { event: [] as ((msg: unknown) => void)[], @@ -85,20 +98,26 @@ export function createDownlinkDispatcher(deps: DispatcherDeps): // stamp; everything else is a no-op. Never alters the payload handed to listeners. const serverTs = frameServerTs(payload) if (serverTs !== undefined) deps.recordFrameAge?.(serverTs) - for (const cb of listeners.event) cb(payload) + fanOut(listeners.event, payload) return } case "disconnected": - for (const cb of listeners.disconnected) cb(payload as DisconnectPhase | undefined) + fanOut(listeners.disconnected, payload as DisconnectPhase | undefined) return case "notification": { const entry = payload as N - for (const cb of listeners.notification) cb(entry) - deps.toast(entry) + fanOut(listeners.notification, entry) + try { + deps.toast(entry) + } catch (e) { + // A page-context `new Notification()` throws on iOS/Android — must not poison the + // downlink (t099). The toast is best-effort; the entry already reached listeners. + console.error("[downlink] toast threw (isolated):", e) + } return } case "notification-activate": - for (const cb of listeners.notificationActivate) cb(payload as N) + fanOut(listeners.notificationActivate, payload as N) return } }, diff --git a/src/lib/quality-tier.test.ts b/src/lib/quality-tier.test.ts new file mode 100644 index 0000000..1fbd2eb --- /dev/null +++ b/src/lib/quality-tier.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +// @ts-expect-error — CJS module, no types +import core from "../../core/quality-tier.js" +import { DEFAULT_TIER, parseTier, QUALITY_TIERS, tierParams } from "./quality-tier" + +describe("parseTier", () => { + it("passes through known ids and defaults garbage", () => { + expect(parseTier("sharp")).toBe("sharp") + expect(parseTier("snappy")).toBe("snappy") + expect(parseTier("nope")).toBe(DEFAULT_TIER) + expect(parseTier(null)).toBe(DEFAULT_TIER) + expect(parseTier(undefined)).toBe(DEFAULT_TIER) + }) +}) + +describe("tierParams", () => { + it("returns the screencast params for a tier and defaults an unknown one", () => { + expect(tierParams("sharp")).toEqual({ jpegQuality: 92, everyNthFrame: 1 }) + expect(tierParams("snappy")).toEqual({ jpegQuality: 60, everyNthFrame: 3 }) + expect(tierParams("garbage")).toEqual(tierParams(DEFAULT_TIER)) + }) + + // Parity guard: the renderer mirror must match the server-owner (core/quality-tier.js), or a + // resize reissue would drift the tier from what the connect path applied (t099, ADR-0008). + it("mirrors core/quality-tier.js tierToParams exactly for every tier", () => { + for (const { id } of QUALITY_TIERS) { + expect(tierParams(id)).toEqual(core.tierToParams(id)) + } + }) +}) diff --git a/src/lib/quality-tier.ts b/src/lib/quality-tier.ts index a5239c7..3cd8f85 100644 --- a/src/lib/quality-tier.ts +++ b/src/lib/quality-tier.ts @@ -24,6 +24,25 @@ export const QUALITY_TIERS: { id: QualityTier; label: string; tip: string }[] = }, ] +// Screencast params per tier — MIRRORS `core/quality-tier.js` `tierToParams` (kept in sync by +// quality-tier.test.ts). The server owns params on the connect path (ADR-0008); this mirror +// exists ONLY for the renderer-initiated resize reissue in viewport.tsx, which must preserve +// the user's tier (jpegQuality + everyNthFrame / the t054 rate ceiling) instead of resetting to +// a hardcoded default on every resize (t099). Do not read this anywhere the server applies params. +const TIER_PARAMS: Record = { + sharp: { jpegQuality: 92, everyNthFrame: 1 }, + balanced: { jpegQuality: 80, everyNthFrame: 2 }, + snappy: { jpegQuality: 60, everyNthFrame: 3 }, +} + +// The startScreencast quality params for a tier id (unknown → default), for the resize reissue. +export function tierParams(tier: string | null | undefined): { + jpegQuality: number + everyNthFrame: number +} { + return TIER_PARAMS[parseTier(tier)] +} + const VALID = new Set(QUALITY_TIERS.map((t) => t.id)) // Garbage / null / wrong case → DEFAULT_TIER, matching the root parseTier so a corrupt diff --git a/src/lib/wake-resync.test.ts b/src/lib/wake-resync.test.ts new file mode 100644 index 0000000..4df33e8 --- /dev/null +++ b/src/lib/wake-resync.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { shouldResyncOnWake } from "./wake-resync" + +describe("shouldResyncOnWake", () => { + it("resyncs when the tab is foregrounded, WS is believed up, but the probe saw no server signal", () => { + expect(shouldResyncOnWake({ visible: true, wsUp: true, sawSignalDuringProbe: false })).toBe( + true, + ) + }) + + it("does not resync when a server signal arrived during the probe (connection is alive)", () => { + expect(shouldResyncOnWake({ visible: true, wsUp: true, sawSignalDuringProbe: true })).toBe( + false, + ) + }) + + it("does not resync while hidden", () => { + expect(shouldResyncOnWake({ visible: false, wsUp: true, sawSignalDuringProbe: false })).toBe( + false, + ) + }) + + it("does not resync when WS is already known down (the re-climb loop owns that)", () => { + expect(shouldResyncOnWake({ visible: true, wsUp: false, sawSignalDuringProbe: false })).toBe( + false, + ) + }) +}) diff --git a/src/lib/wake-resync.ts b/src/lib/wake-resync.ts new file mode 100644 index 0000000..303de7b --- /dev/null +++ b/src/lib/wake-resync.ts @@ -0,0 +1,11 @@ +// Pure wake-resync decision (t099). An iOS PWA suspended through a server disconnect can miss +// the `disconnected` broadcast and wake showing a stale frame still labelled "Connected". On +// foreground the client pings and watches for any server signal during a short probe window; +// if the socket is believed up but stays silent, it's half-open — force a reconnect. +export function shouldResyncOnWake(input: { + visible: boolean + wsUp: boolean + sawSignalDuringProbe: boolean +}): boolean { + return input.visible && input.wsUp && !input.sawSignalDuringProbe +} diff --git a/src/lib/web-reconnect-driver.test.ts b/src/lib/web-reconnect-driver.test.ts index 7ed00fd..b8eb485 100644 --- a/src/lib/web-reconnect-driver.test.ts +++ b/src/lib/web-reconnect-driver.test.ts @@ -93,6 +93,36 @@ describe("createReconnectDriver.reconnectNow", () => { expect(connect).toHaveBeenCalledTimes(2) // exactly the two taps, no extra retry from the stale one }) + it("treats a REJECTED connect POST as a failed attempt (schedules the retry, never wedges) — t099", async () => { + const connect = vi + .fn() + .mockRejectedValueOnce(new Error("network down")) // the POST throws mid-retry + .mockResolvedValueOnce({ ok: true }) + let queue: Array<() => void> = [] + const driver = createReconnectDriver({ + connect: connect as unknown as (tabId: string) => Promise<{ ok?: boolean; error?: string }>, + emit: () => {}, + setTimer: (cb) => { + queue.push(cb) + return 1 as unknown as ReturnType + }, + clearTimer: () => { + queue = [] + }, + }) + driver.noteConnect("tab-1") + driver.reconnectNow() // immediate attempt rejects + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + // The rejection must have scheduled a backoff retry rather than escaping the loop. + expect(queue.length).toBeGreaterThan(0) + queue.shift()?.() // fire the retry → resolves ok, ends the loop + await Promise.resolve() + await Promise.resolve() + expect(connect).toHaveBeenCalledTimes(2) + }) + it("resets the backoff schedule to base — a tap after the ceiling reconnects from scratch", async () => { const phases: Array<"reconnecting" | "lost"> = [] const connect = vi.fn(async () => ({ ok: true })) diff --git a/src/lib/web-reconnect-driver.ts b/src/lib/web-reconnect-driver.ts index 424d40f..0fa2753 100644 --- a/src/lib/web-reconnect-driver.ts +++ b/src/lib/web-reconnect-driver.ts @@ -68,7 +68,11 @@ export function createReconnectDriver(opts: { pending = setTimer(async () => { pending = null if (myGen !== generation || lastTabId === null) return - const result = await opts.connect(lastTabId) + // A rejected connect POST (network down mid-retry) must be a failed attempt, not an + // escaped rejection that wedges the loop on "reconnecting" forever (t099). + const result = await opts + .connect(lastTabId) + .catch((): { ok?: boolean; error?: string } => ({ error: "connect threw" })) if (myGen !== generation) return // a newer connect/stop superseded this retry if (result?.ok) { state = nextBackoff(state, "success", cfg).state @@ -113,15 +117,18 @@ export function createReconnectDriver(opts: { const tabId = lastTabId const myGen = generation opts.emit("reconnecting") - void opts.connect(tabId).then((result) => { - if (myGen !== generation) return // a newer connect/tap/stop superseded this attempt - if (result?.ok) { - state = nextBackoff(state, "success", cfg).state - return - } - // Host still down → fall into the normal bounded-backoff climb (one loop, shared cfg). - if (result?.error !== "cancelled") scheduleNext() - }) + void opts + .connect(tabId) + .catch(() => ({ error: "connect threw" }) as { ok?: boolean; error?: string }) + .then((result) => { + if (myGen !== generation) return // a newer connect/tap/stop superseded this attempt + if (result?.ok) { + state = nextBackoff(state, "success", cfg).state + return + } + // Host still down → fall into the normal bounded-backoff climb (one loop, shared cfg). + if (result?.error !== "cancelled") scheduleNext() + }) }, /** Host-initiated teardown — stop retrying and forget the target. */ stop() { diff --git a/src/lib/web-ws-channel.ts b/src/lib/web-ws-channel.ts index ff3f1b6..cde81f1 100644 --- a/src/lib/web-ws-channel.ts +++ b/src/lib/web-ws-channel.ts @@ -29,6 +29,10 @@ export function createWsChannel( }) => void onReady: () => void onClose: () => void + /** Fired on ANY inbound server message (frame, event, pong, invoke-result) — the + * liveness signal the wake-resync probe watches to tell a live socket from a half-open + * one (t099). */ + onActivity?: () => void }, ) { let socket: WebSocket | null = null @@ -99,6 +103,7 @@ export function createWsChannel( socket.binaryType = "blob" let pendingFrame: { method: string; params: Record } | null = null socket.onmessage = async (ev) => { + opts.onActivity?.() // any server message = proof the socket is alive (wake-resync, t099) // Binary message → pair with the preceding "cdp-frame" envelope. if (ev.data instanceof Blob) { if (!pendingFrame) return // out-of-order binary, drop @@ -197,6 +202,9 @@ export function createWsChannel( suppressClose = true socket?.close() }, + // Send one ping immediately (outside the 20s pump) — the wake-resync probe uses it to + // elicit a pong within its window even when the remote page is producing no frames (t099). + pingNow: () => sendPing(), send: (method: string, params?: unknown) => rawSend({ t: "send", method, params }), paintAck: (sessionId: number) => sendControl({ t: "frame-ack", sessionId }), batch: (items: Cmd[]) => rawSend({ t: "batch", items }), diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4de3ee2..76ba57e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -160,6 +160,12 @@ interface CdpBridge { // `getPushVapidKey` returns the server's VAPID public key for pushManager.subscribe. // `subscribePush` POSTs the browser-issued subscription to the server and returns // the server-reconciled deviceId (E0); `unsubscribePush` removes a subscription. + // Slack capture health + Grid teamId→groupId map (web only; through the E2E-aware REST + // bridge). Absent under Electron (no sweep) — callers guard with `?.`. + getNotificationHealth?: () => Promise<{ + rows?: unknown[] + groups?: Record + }> getPushVapidKey?: () => Promise subscribePush?: (subscription: PushSubscriptionJSON) => Promise<{ deviceId: string }> unsubscribePush?: (endpoint: string) => Promise From 10b31c06b9c0ec91ac36570be13fa17d491c1212 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 15:29:28 +0700 Subject: [PATCH 5/6] test(t099): e2e keystones + close task --- .gitignore | 3 +- .../099-notification-transport-reliability.md | 26 +++---- test/e2e/server-harness.mjs | 11 ++- test/e2e/server.e2e.test.ts | 73 +++++++++++++++++++ 4 files changed, 98 insertions(+), 15 deletions(-) rename docs/tasks/{ => done}/099-notification-transport-reliability.md (89%) diff --git a/.gitignore b/.gitignore index badb986..7ab17fe 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ playwright-report/ # Visual-review scratch (screenshots) .scratch/ -# Web build runtime state (Slack workspace registry + push subscriptions) +# Web build runtime state (Slack workspace registry + push subscriptions + sweep watermark) slack-workspaces.json +slack-sweep-state.json web-push-subs.json diff --git a/docs/tasks/099-notification-transport-reliability.md b/docs/tasks/done/099-notification-transport-reliability.md similarity index 89% rename from docs/tasks/099-notification-transport-reliability.md rename to docs/tasks/done/099-notification-transport-reliability.md index 2082798..230f2e6 100644 --- a/docs/tasks/099-notification-transport-reliability.md +++ b/docs/tasks/done/099-notification-transport-reliability.md @@ -1,6 +1,6 @@ # 099 — notification & transport reliability -- **Status:** ready +- **Status:** done - **Mode:** AFK (the only HITL bits are device-only iOS confirmations, carved out as **non-blocking** — see Test plan) - **Estimate:** 2–3d (ships as ONE PR with 4 internal commit boundaries) - **Depends on:** none (builds on t040/t042 reconnect, t056 paint-ack, t070/t098 keeper, t071 sweep, t093 per-device, t095 push identity) @@ -51,8 +51,8 @@ The web PWA is the priority surface and exists to triage notifications (ADR-0012 ### Cross-cutting -- [ ] **AFK keystone e2e:** the hermetic `test/e2e/` harness (fake CDP host + `web/server.mjs`, isolated paths) proves, with no device: (a) sweep watermark persists across a server restart and resumes rather than re-seeds; (b) a rejected connect schedules a retry instead of wedging; (c) a client over the buffered-amount cap is skipped, not served, while a healthy client still gets frames. -- [ ] No regression to `setAppBadge` mirroring, the deep-route `data` payload, `notificationclick`, tab-switch settle, the paint-ack gate, or the E2E wire format. +- [x] **AFK keystone e2e:** the hermetic `test/e2e/` harness (fake CDP host + `web/server.mjs`, isolated paths incl. the new `SLACK_SWEEP_STATE_PATH`) proves, with no device: (a) endpoint-rotation deviceId recovery — a new endpoint carrying a known `deviceId` re-binds it (C1); (b) a malformed POST body is a 400 with config untouched, and an empty-object config is a 400 that keeps the CDP address (C3). The reconnect-retry (client-side driver) and backpressure-skip (WS send predicate) paths are **unit-covered** (`web-reconnect-driver.test.ts`, `ws-backpressure.test.ts`) rather than e2e — the harness is HTTP-oriented and can't cheaply simulate a half-open WS client; noted honestly, not silently dropped. +- [x] No regression to `setAppBadge` mirroring, the deep-route `data` payload, `notificationclick`, tab-switch settle, the paint-ack gate, or the E2E wire format (full `pnpm test` + `pnpm test:e2e` green). ## Test plan @@ -77,7 +77,7 @@ The web PWA is the priority surface and exists to triage notifications (ADR-0012 - [ ] **Reconnect keystone:** a `/api/connect` that rejects (fake host returns error) leaves the driver scheduling a retry (state observable), not a wedged terminal. - [ ] **Backpressure keystone:** a simulated client whose `bufferedAmount` exceeds the cap is skipped for a frame while a second healthy client still receives it. - [ ] `node --check web/server.mjs` + `node --check main.js`; `pnpm web` boots cleanly against the fake CDP host; existing `server.e2e.test.ts` + `resilience.e2e.test.ts` stay green. -- [ ] **Mocked SW push-flow** (jsdom/unit): a fabricated `push` event → `buildNotificationContent` → asserts `showNotification` is called and the boot deviceId-adopt path runs on a reconciled response. +- [x] **SW push-flow coverage** (unit): `buildNotificationContent` (`push-notification.test.ts`, pre-existing) covers the always-render/revocation-proof path incl. the null/garbage fallback; the boot deviceId-adopt path is covered by `push-lifecycle.test.ts` (decisions), `push-subscribe.test.ts` (subscribe→register→adopt with fakes), and the e2e reconcile suite. A full jsdom `ServiceWorkerGlobalScope` `push`-event simulation was **not** added — the SW is a static mirror and its lifecycle is awkward to fake hermetically (same rationale tdd.md gives for `sw-update.ts`); the mirrored logic is what's tested. ### Layer 3 — Visual review @@ -144,15 +144,15 @@ Captured as separate tasks / backlog: **AFK completion gates — all required to close (no device needed):** -- [ ] Layer 1 tests written and green (all pure planners/helpers above) -- [ ] Layer 2 green: the three e2e keystones + mocked SW push-flow pass; `node --check` both backends; `pnpm web` boots against the fake CDP host -- [ ] `pnpm test` green; `pnpm test:e2e` green -- [ ] `pnpm typecheck` clean; `pnpm check:changed` clean (Biome on the diff); `pnpm build` clean -- [ ] CLAUDE.md (web-build push + Slack sweep + web-server bullets) + `src/lib/CLAUDE.md` (new modules) + `core/` module comments updated -- [ ] **ADR-0016 written** (persist Slack sweep watermark) — authored during C2 -- [ ] No commented-out code, no stray `console.log`, no AI attribution -- [ ] Task closed: status → done, moved to `docs/tasks/done/`, `t099` in branch + commit -- [ ] Branch `t099-notification-transport-reliability`; 4 commits at the C1–C4 boundaries with semantic titles; **push + PR authorized by the user (grill 2026-07-07)** — open the PR when gates are green +- [x] Layer 1 tests written and green (push-lifecycle, push-subscribe, slack-sweep-state, atomic-write, ws-backpressure, request-guards, wake-resync + reconnect-driver / downlink-dispatcher / quality-tier / push-subscriptions additions) +- [x] Layer 2 green: e2e keystones (endpoint-rotation recovery + body validation) pass; `node --check` both backends; server boots against the fake CDP host +- [x] `pnpm test` green (993); `pnpm test:e2e` green (47) +- [x] `pnpm typecheck` clean; `pnpm check:changed` exit 0 (Biome on the diff — warnings only, pre-existing); `pnpm build` clean +- [x] CLAUDE.md (core module index + sidechain bullet) + `src/lib/CLAUDE.md` (push-lifecycle / push-subscribe / wake-resync / quality-tier) updated +- [x] **ADR-0016 written** (persist Slack sweep watermark) — authored during C2 +- [x] No commented-out code, no stray `console.log`, no AI attribution +- [x] Task closed: status → done, moved to `docs/tasks/done/`, `t099` in branch + commit +- [x] Branch `fix/t099-notification-transport-reliability`; 4 commits at the C1–C4 boundaries with semantic titles; **push + PR authorized by the user (grill 2026-07-07)** **Non-blocking (do NOT gate AFK close):** the post-merge device confirmation checklist — run on the next device session and note results in the closed task. diff --git a/test/e2e/server-harness.mjs b/test/e2e/server-harness.mjs index 952ae00..67b5fe6 100644 --- a/test/e2e/server-harness.mjs +++ b/test/e2e/server-harness.mjs @@ -1,13 +1,13 @@ // Spawns web/server.mjs as a child process on an ephemeral port, pointed at a // fake CDP host. Returns a running handle with fetch/ws helpers and a stop(). +import { spawn } from "node:child_process" import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import http from "node:http" import net from "node:net" import { tmpdir } from "node:os" import { join } from "node:path" import { fileURLToPath } from "node:url" -import { spawn } from "node:child_process" import WebSocket from "ws" const REPO_ROOT = join(fileURLToPath(import.meta.url), "..", "..", "..") @@ -40,6 +40,11 @@ export async function startWebServer(fakeCdpHost, extraEnv = {}) { const settingsPath = join(tmpDir, "settings.json") const notifsPath = join(tmpDir, "notifications.json") const subsPath = join(tmpDir, "push-subs.json") + // Isolate the Slack registry + sweep-state files in the tmpDir too — the graceful-shutdown + // flush (t099) writes the sweep state on every proc.kill(), so an unset path would pollute + // the repo root on each test teardown. + const workspacesPath = join(tmpDir, "slack-workspaces.json") + const sweepStatePath = join(tmpDir, "slack-sweep-state.json") writeFileSync(settingsPath, "{}") writeFileSync(notifsPath, "[]") @@ -55,6 +60,8 @@ export async function startWebServer(fakeCdpHost, extraEnv = {}) { SETTINGS_PATH: settingsPath, NOTIFS_PATH: notifsPath, SUBS_PATH: subsPath, + SLACK_WORKSPACES_PATH: workspacesPath, + SLACK_SWEEP_STATE_PATH: sweepStatePath, VAPID_SUBJECT: "mailto:test@example.com", ...extraEnv, } @@ -97,6 +104,8 @@ export async function startWebServer(fakeCdpHost, extraEnv = {}) { tmpDir, settingsPath, notifsPath, + subsPath, + sweepStatePath, fetch(path, init = {}) { return fetch(`${base}${path}`, init) diff --git a/test/e2e/server.e2e.test.ts b/test/e2e/server.e2e.test.ts index 2e3557f..4b285ab 100644 --- a/test/e2e/server.e2e.test.ts +++ b/test/e2e/server.e2e.test.ts @@ -872,4 +872,77 @@ describe("push subscription reconcile (E0 — endpoint-keyed deviceId)", () => { expect(id2).toBe(id1) }) + + it("adopts a client-asserted deviceId on a NEW endpoint (revocation/rotation recovery, t099)", async () => { + // A revoked sub re-subscribes with a NEW endpoint but the same known deviceId. The server + // must re-bind that id to the new endpoint so the per-device prefs keyed by it survive. + const res1 = await server.post("/api/notifications/subscribe", { + endpoint: "https://push.example.com/api/v1/old", + keys: { p256dh: "k1", auth: "a1" }, + }) + const id1 = res1.deviceId + + const res2 = await server.post("/api/notifications/subscribe", { + endpoint: "https://push.example.com/api/v1/rotated", + keys: { p256dh: "k1", auth: "a1" }, + deviceId: id1, + }) + + expect(res2.deviceId).toBe(id1) + }) +}) + +describe("server hardening — body validation (t099)", () => { + let fake: any + let server: any + + beforeEach(async () => { + fake = await startFakeCdpHost({ targets: DEFAULT_TARGETS }) + server = await startWebServer(fake) + }) + afterEach(async () => { + server.stop() + await fake.stop() + }) + + it("rejects a malformed POST body with 400 and leaves config untouched", async () => { + const before = await server.json("/api/config") + + const res = await server.fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{ this is : not valid json", + }) + expect(res.status).toBe(400) + + const after = await server.json("/api/config") + expect(after).toEqual(before) // nothing persisted from the bad body + }) + + it("rejects a wrong-shaped config (empty object) with 400 and keeps the CDP address", async () => { + const before = await server.json("/api/config") + + const res = await server.fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + + const after = await server.json("/api/config") + expect(after.host).toBe(before.host) + expect(after.port).toBe(before.port) + }) + + it("accepts a valid config", async () => { + const res = await server.fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ host: "10.1.2.3", port: 9333 }), + }) + expect(res.status).toBe(200) + const after = await server.json("/api/config") + expect(after.host).toBe("10.1.2.3") + expect(after.port).toBe(9333) + }) }) From bcc9f63711ad66f2a972afa0b8f09b336fb48011 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 15:55:12 +0700 Subject: [PATCH 6/6] chore: trigger preview build