From b01ad7cacba8d8fcedb8f73a068c4674478f5b79 Mon Sep 17 00:00:00 2001 From: djtzemx Date: Tue, 22 Sep 2026 07:08:54 +0800 Subject: [PATCH 1/3] fix(dsh-plugin): reconnect the observation stream after a fatal failure The observation store attached only an onmessage handler to the plugin's SSE stream and start() returns early while the store is already marked started, so a fatal EventSource failure left the view empty until the page was reloaded. An EventSource never retries a non-200 response, which is exactly what happens when the page loads before the observation route is registered, or right after a plugin reload: the console reports 404 on /bsk-observation/events while /bsk-observation/state and a direct SSE request both answer normally, and the sidebar sits at "no session" with no way to recover except a page reload. - attach an onerror handler and rebuild the stream with a bounded backoff when readyState is CLOSED; transient drops keep readyState 0/1 and are still left to the EventSource's own retry - reset the backoff on any healthy frame, and cancel a pending reconnect in stop() - republish the snapshot whenever the stream is (re)created so `subscribed` reflects reality - surface `subscribed` in the overlay and sidebar empty state: a dead feed now reads "connecting..." / "connection lost - retrying" instead of "no session" - keep the floating card mounted while reconnecting (it used to return null as soon as there were no sessions, showing nothing at all) --- .../src/client/ObservationOverlay.tsx | 25 ++++-- .../src/client/observation-sidebar.tsx | 1 + .../src/client/observation-store.ts | 46 +++++++++++ .../tests/client/observation-store.test.ts | 80 +++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index c1f30ec3..063d76f4 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -325,6 +325,8 @@ export function OverlayBody(props: { focus: SessionObservation | undefined; sessions: readonly SessionObservation[]; available: boolean; + /** Whether the live event stream is connected; false while reconnecting. */ + subscribed: boolean; pinnedId: string | null; onTogglePin: (sessionId: string) => void; now: number; @@ -342,6 +344,7 @@ export function OverlayBody(props: { focus, sessions, available, + subscribed, pinnedId, onTogglePin, now, @@ -382,9 +385,11 @@ export function OverlayBody(props: { const statusText = !available ? "browser unavailable" - : focus === undefined - ? "no session" - : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; + : !subscribed + ? "connecting…" + : focus === undefined + ? "no session" + : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; return ( @@ -453,9 +458,11 @@ export function OverlayBody(props: {
{!available ? "last frame kept" - : thumb?.status === "error" - ? "frame unavailable" - : "waiting for page"} + : !subscribed + ? "connection lost — retrying" + : thumb?.status === "error" + ? "frame unavailable" + : "waiting for page"}
)} {thumb?.status === "error" ? ( @@ -620,6 +627,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) focus={focus} sessions={snapshot.sessions} available={snapshot.available} + subscribed={snapshot.subscribed} pinnedId={pinnedId} onTogglePin={onTogglePin} now={now} @@ -650,8 +658,9 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) // The sidebar tab is the carrier now — no floating card, no capsule. if (!presentation.floating) return null; - // Hidden while no owned session exists (and no PiP is up). - if (snapshot.sessions.length === 0) return null; + // Hidden while no owned session exists and the feed is healthy; a dead feed + // still renders so the user sees "connecting…" instead of nothing. + if (snapshot.sessions.length === 0 && snapshot.subscribed) return null; if (collapsed) { const state = focus !== undefined ? statusOf(focus) : "idle"; diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 6894c809..7d4d4527 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -100,6 +100,7 @@ export function ObservationSidebarTab({ focus={focus} sessions={snapshot.sessions} available={snapshot.available} + subscribed={snapshot.subscribed} pinnedId={pinnedId} onTogglePin={onTogglePin} now={now} diff --git a/packages/dsh-plugin-browserskill/src/client/observation-store.ts b/packages/dsh-plugin-browserskill/src/client/observation-store.ts index 4bbb7aa4..c88dca3c 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-store.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-store.ts @@ -12,6 +12,10 @@ import { ObservationPresentation } from "./observation-presentation"; export interface EventSourceLike { onmessage: ((event: { data: string }) => void) | null; + /** Present on a real EventSource; test doubles may omit it. */ + onerror?: ((event: unknown) => void) | null; + /** 0 CONNECTING, 1 OPEN, 2 CLOSED; undefined on test doubles. */ + readyState?: number; close(): void; } @@ -52,6 +56,14 @@ const EVENTS_URL = "/bsk-observation/events"; const INTERRUPT_URL = "/bsk-observation/interrupt"; const STOP_URL = "/bsk-observation/stop"; const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000]; +/** + * Backoff for re-creating the live event stream after a *fatal* failure. + * A non-200 response (for example the observation route answering 404 while + * the plugin is still starting, or right after a plugin reload) is terminal + * for an EventSource: the browser never retries it. Without this the view + * stays empty until the page is reloaded. + */ +const EVENT_STREAM_RETRY_DELAYS_MS = [1000, 3000, 10000, 30000]; function revoke(url: string | undefined): void { if (url !== undefined && typeof URL.revokeObjectURL === "function") { @@ -71,6 +83,9 @@ export class ObservationClientStore { private readonly retryCounts = new Map(); private listeners = new Set<() => void>(); private events: EventSourceLike | undefined; + /** Pending re-creation of the event stream after a fatal failure. */ + private reconnectTimer: ReturnType | undefined; + private reconnectAttempts = 0; private snapshot: OverlaySnapshot = { sessions: [], subscribed: false, @@ -145,6 +160,8 @@ export class ObservationClientStore { this.events = events; events.onmessage = (message) => { if (!this.started || this.events !== events) return; + // Healthy traffic resets the backoff. + this.reconnectAttempts = 0; let event: ObservationEvent; try { event = JSON.parse(message.data) as ObservationEvent; @@ -153,6 +170,32 @@ export class ObservationClientStore { } this.apply(event); }; + // A non-200 response is fatal for an EventSource: the browser fails the + // connection permanently and never retries it. Transient drops keep + // readyState 0/1 and are still retried by the EventSource itself, so only + // a CLOSED (2) readyState asks us to rebuild the stream. + events.onerror = () => { + if (!this.started || this.events !== events) return; + if (events.readyState !== undefined && events.readyState !== 2) return; + this.scheduleReconnect(); + }; + // `subscribed` is derived from `this.events`, so the snapshot has to be + // republished whenever the stream is (re)created. + this.publish(); + } + + /** Re-create the stream after a fatal failure, with a bounded backoff. */ + private scheduleReconnect(): void { + if (this.reconnectTimer !== undefined) return; + const index = Math.min(this.reconnectAttempts, EVENT_STREAM_RETRY_DELAYS_MS.length - 1); + const delay = EVENT_STREAM_RETRY_DELAYS_MS[index]; + if (delay === undefined) return; + this.reconnectAttempts += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (!this.started) return; + this.connectEvents(); + }, delay); } /** Every initial connection and reconnect starts with the server's snapshot. */ @@ -168,6 +211,9 @@ export class ObservationClientStore { this.events = undefined; this.started = false; previous?.close(); + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + this.reconnectAttempts = 0; this.clearThumbnails(); this.sessions.clear(); this.available = true; diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts b/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts index aab72298..68b4b9b6 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts +++ b/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts @@ -457,3 +457,83 @@ describe("thumbnail viewer leases", () => { expect(esInstances).toHaveLength(2); }); }); + +describe("event stream recovery", () => { + function streamAt(sources: EventSourceLike[], index: number): EventSourceLike { + const source = sources[index]; + if (source === undefined) throw new Error(`no event stream at index ${index}`); + return source; + } + + it("recreates a fatally failed stream after the backoff", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances, eventUrls } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + expect(store.getSnapshot().subscribed).toBe(true); + + // A 404 from the observation route is fatal: CLOSED, never retried by the browser. + const failed = streamAt(esInstances, 0); + failed.readyState = 2; + failed.onerror?.({}); + expect(esInstances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(esInstances).toHaveLength(2); + expect(eventUrls).toEqual([ + "/bsk-observation/events?thumbnails=0", + "/bsk-observation/events?thumbnails=0", + ]); + expect(failed.close).toHaveBeenCalledOnce(); + expect(store.getSnapshot().subscribed).toBe(true); + expect(store.getSnapshot().sessions).toEqual([OBS_IDLE]); + }); + + it("leaves a transient drop to the EventSource's own retry", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + + const dropped = streamAt(esInstances, 0); + dropped.readyState = 1; + dropped.onerror?.({}); + await vi.advanceTimersByTimeAsync(60_000); + expect(esInstances).toHaveLength(1); + }); + + it("resets the backoff after healthy traffic", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + + const first = streamAt(esInstances, 0); + first.readyState = 2; + first.onerror?.({}); + await vi.advanceTimersByTimeAsync(1000); + expect(esInstances).toHaveLength(2); + + // Any frame proves the new stream is healthy, so the next failure waits 1s again. + const second = streamAt(esInstances, 1); + emit(second, { type: "upsert", session: OBS_BUSY }); + second.readyState = 2; + second.onerror?.({}); + await vi.advanceTimersByTimeAsync(1000); + expect(esInstances).toHaveLength(3); + }); + + it("cancels a pending reconnect on stop", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + + const failed = streamAt(esInstances, 0); + failed.readyState = 2; + failed.onerror?.({}); + store.stop(); + await vi.advanceTimersByTimeAsync(60_000); + expect(esInstances).toHaveLength(1); + }); +}); From d102adcbd9951e2fe518d42deb3b9d1f9ee27e6f Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sat, 26 Sep 2026 23:36:31 +0800 Subject: [PATCH 2/3] fix(dsh-plugin): track observation stream outages explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subscribed` is derived from whether an EventSource object exists, so it stays true for the whole outage: the failed stream is still held until the rebuilt one replaces it. The "connecting…" states therefore never showed during a real failure, while the floating card, now gated on `subscribed`, was committed for one frame with "connection lost — retrying" on every page load that had no sessions. - add `reconnecting` to the snapshot: set on any stream error, including transient drops the browser retries itself, and cleared by the next well-formed frame on the current stream - show "reconnecting…" in the overlay and sidebar header while it is set, in place of the last session status that could no longer be trusted - keep the floating card hidden without sessions, as before this change - cancel a pending reconnect whenever the stream is rebuilt, so a thumbnail switch during the backoff is not torn down by the stale timer - drop the extra publish per stream rebuild that only served `subscribed` --- .../src/client/ObservationOverlay.tsx | 29 +++++----- .../src/client/observation-sidebar.tsx | 2 +- .../src/client/observation-store.ts | 25 ++++++--- .../tests/client/observation-overlay.test.tsx | 38 +++++++++++++ .../tests/client/observation-store.test.ts | 55 +++++++++++++++++-- 5 files changed, 121 insertions(+), 28 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index 063d76f4..4de2b3fa 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -325,8 +325,8 @@ export function OverlayBody(props: { focus: SessionObservation | undefined; sessions: readonly SessionObservation[]; available: boolean; - /** Whether the live event stream is connected; false while reconnecting. */ - subscribed: boolean; + /** The live feed dropped; sessions shown are the last ones received. */ + reconnecting: boolean; pinnedId: string | null; onTogglePin: (sessionId: string) => void; now: number; @@ -344,7 +344,7 @@ export function OverlayBody(props: { focus, sessions, available, - subscribed, + reconnecting, pinnedId, onTogglePin, now, @@ -383,10 +383,10 @@ export function OverlayBody(props: { void store.interrupt(focus.sessionId).finally(() => setInterrupting(false)); }; - const statusText = !available - ? "browser unavailable" - : !subscribed - ? "connecting…" + const statusText = reconnecting + ? "reconnecting…" + : !available + ? "browser unavailable" : focus === undefined ? "no session" : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; @@ -458,11 +458,9 @@ export function OverlayBody(props: {
{!available ? "last frame kept" - : !subscribed - ? "connection lost — retrying" - : thumb?.status === "error" - ? "frame unavailable" - : "waiting for page"} + : thumb?.status === "error" + ? "frame unavailable" + : "waiting for page"}
)} {thumb?.status === "error" ? ( @@ -627,7 +625,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) focus={focus} sessions={snapshot.sessions} available={snapshot.available} - subscribed={snapshot.subscribed} + reconnecting={snapshot.reconnecting} pinnedId={pinnedId} onTogglePin={onTogglePin} now={now} @@ -658,9 +656,8 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) // The sidebar tab is the carrier now — no floating card, no capsule. if (!presentation.floating) return null; - // Hidden while no owned session exists and the feed is healthy; a dead feed - // still renders so the user sees "connecting…" instead of nothing. - if (snapshot.sessions.length === 0 && snapshot.subscribed) return null; + // Hidden while no owned session exists (and no PiP is up). + if (snapshot.sessions.length === 0) return null; if (collapsed) { const state = focus !== undefined ? statusOf(focus) : "idle"; diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 7d4d4527..89e7d028 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -100,7 +100,7 @@ export function ObservationSidebarTab({ focus={focus} sessions={snapshot.sessions} available={snapshot.available} - subscribed={snapshot.subscribed} + reconnecting={snapshot.reconnecting} pinnedId={pinnedId} onTogglePin={onTogglePin} now={now} diff --git a/packages/dsh-plugin-browserskill/src/client/observation-store.ts b/packages/dsh-plugin-browserskill/src/client/observation-store.ts index c88dca3c..0c0e86be 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-store.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-store.ts @@ -50,6 +50,11 @@ export interface OverlaySnapshot { readonly displayFrames: Readonly>; /** False when the host reports the browser/daemon as unreachable. */ readonly available: boolean; + /** + * True from a stream error until the stream delivers a frame again; + * meanwhile sessions and availability are the last ones received. + */ + readonly reconnecting: boolean; } const EVENTS_URL = "/bsk-observation/events"; @@ -92,8 +97,10 @@ export class ObservationClientStore { thumbnails: {}, displayFrames: {}, available: true, + reconnecting: false, }; private available = true; + private reconnecting = false; private started = false; /** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */ private consumers = 0; @@ -115,6 +122,7 @@ export class ObservationClientStore { thumbnails: Object.fromEntries(this.thumbs), displayFrames: this.buildDisplayFrames(), available: this.available, + reconnecting: this.reconnecting, }; for (const listener of [...this.listeners]) listener(); } @@ -151,6 +159,8 @@ export class ObservationClientStore { } private connectEvents(): void { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; const previous = this.events; this.events = undefined; previous?.close(); @@ -160,14 +170,15 @@ export class ObservationClientStore { this.events = events; events.onmessage = (message) => { if (!this.started || this.events !== events) return; - // Healthy traffic resets the backoff. - this.reconnectAttempts = 0; let event: ObservationEvent; try { event = JSON.parse(message.data) as ObservationEvent; } catch { return; } + // Healthy traffic resets the backoff and ends the outage. + this.reconnectAttempts = 0; + this.reconnecting = false; this.apply(event); }; // A non-200 response is fatal for an EventSource: the browser fails the @@ -176,12 +187,11 @@ export class ObservationClientStore { // a CLOSED (2) readyState asks us to rebuild the stream. events.onerror = () => { if (!this.started || this.events !== events) return; - if (events.readyState !== undefined && events.readyState !== 2) return; - this.scheduleReconnect(); + if (events.readyState === undefined || events.readyState === 2) this.scheduleReconnect(); + if (this.reconnecting) return; + this.reconnecting = true; + this.publish(); }; - // `subscribed` is derived from `this.events`, so the snapshot has to be - // republished whenever the stream is (re)created. - this.publish(); } /** Re-create the stream after a fatal failure, with a bounded backoff. */ @@ -214,6 +224,7 @@ export class ObservationClientStore { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.reconnectAttempts = 0; + this.reconnecting = false; this.clearThumbnails(); this.sessions.clear(); this.available = true; diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index e2573971..43595331 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -197,6 +197,44 @@ describe("ObservationOverlay", () => { expect(screen.getByTestId("obs-card")).toBeTruthy(); }); + it("never commits the card without sessions, even while the feed is down", async () => { + const h = makeHarness([]); + const inserted: Node[] = []; + const collect = (records: MutationRecord[]) => { + for (const record of records) inserted.push(...record.addedNodes); + }; + const observer = new MutationObserver(collect); + observer.observe(document.body, { childList: true, subtree: true }); + render(); + await waitFor(() => expect(h.es).not.toThrow()); + act(() => { + h.es().readyState = 2; + h.es().onerror?.({}); + }); + collect(observer.takeRecords()); + observer.disconnect(); + expect(h.store.getSnapshot().reconnecting).toBe(true); + const cardCommitted = inserted.some( + (node) => node instanceof Element && node.closest("[data-obs-card]") !== null, + ); + expect(cardCommitted).toBe(false); + }); + + it("marks a live session as reconnecting until the feed recovers", async () => { + const h = makeHarness([BUSY]); + render(); + await screen.findByText(/s1 · clicking/); + act(() => { + h.es().readyState = 0; + h.es().onerror?.({}); + }); + expect(screen.getByTestId("obs-header").textContent).toContain("reconnecting…"); + expect(screen.queryByText(/s1 · clicking/)).toBeNull(); + act(() => h.emitRaw({ type: "snapshot", sessions: [BUSY], available: true })); + expect(screen.getByTestId("obs-header").textContent).not.toContain("reconnecting"); + expect(screen.getByText(/s1 · clicking/)).toBeTruthy(); + }); + it("shows the status row and a placeholder without a thumbnail", async () => { const h = makeHarness([BUSY]); render(); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts b/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts index 68b4b9b6..8fd225ba 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts +++ b/packages/dsh-plugin-browserskill/tests/client/observation-store.test.ts @@ -470,13 +470,14 @@ describe("event stream recovery", () => { const { store, esInstances, eventUrls } = harness([OBS_IDLE]); store.start(); await Promise.resolve(); - expect(store.getSnapshot().subscribed).toBe(true); + expect(store.getSnapshot().reconnecting).toBe(false); // A 404 from the observation route is fatal: CLOSED, never retried by the browser. const failed = streamAt(esInstances, 0); failed.readyState = 2; failed.onerror?.({}); expect(esInstances).toHaveLength(1); + expect(store.getSnapshot()).toMatchObject({ reconnecting: true, sessions: [OBS_IDLE] }); await vi.advanceTimersByTimeAsync(1000); expect(esInstances).toHaveLength(2); @@ -485,8 +486,26 @@ describe("event stream recovery", () => { "/bsk-observation/events?thumbnails=0", ]); expect(failed.close).toHaveBeenCalledOnce(); - expect(store.getSnapshot().subscribed).toBe(true); - expect(store.getSnapshot().sessions).toEqual([OBS_IDLE]); + expect(store.getSnapshot()).toMatchObject({ reconnecting: false, sessions: [OBS_IDLE] }); + }); + + it("stays reconnecting until a rebuilt stream delivers a frame", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + + const failed = streamAt(esInstances, 0); + failed.readyState = 2; + failed.onerror?.({}); + await vi.advanceTimersByTimeAsync(999); + expect(esInstances).toHaveLength(1); + // The rebuilt stream exists before its snapshot frame arrives. + vi.advanceTimersByTime(1); + expect(esInstances).toHaveLength(2); + expect(store.getSnapshot().reconnecting).toBe(true); + await Promise.resolve(); + expect(store.getSnapshot().reconnecting).toBe(false); }); it("leaves a transient drop to the EventSource's own retry", async () => { @@ -496,10 +515,37 @@ describe("event stream recovery", () => { await Promise.resolve(); const dropped = streamAt(esInstances, 0); - dropped.readyState = 1; + dropped.readyState = 0; dropped.onerror?.({}); + expect(store.getSnapshot().reconnecting).toBe(true); await vi.advanceTimersByTimeAsync(60_000); expect(esInstances).toHaveLength(1); + + // The browser's own reconnect replays the snapshot on the same stream. + emit(dropped, { type: "snapshot", sessions: [OBS_BUSY], available: true }); + expect(store.getSnapshot()).toMatchObject({ reconnecting: false, sessions: [OBS_BUSY] }); + }); + + it("lets a thumbnail switch take over a pending reconnect", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { store, esInstances, eventUrls } = harness([OBS_IDLE]); + store.start(); + await Promise.resolve(); + + const failed = streamAt(esInstances, 0); + failed.readyState = 2; + failed.onerror?.({}); + store.watchThumbnails(); + await Promise.resolve(); + expect(store.getSnapshot().reconnecting).toBe(false); + + // The stale backoff timer must not tear down the healthy replacement. + await vi.advanceTimersByTimeAsync(60_000); + expect(eventUrls).toEqual([ + "/bsk-observation/events?thumbnails=0", + "/bsk-observation/events?thumbnails=1", + ]); + expect(streamAt(esInstances, 1).close).not.toHaveBeenCalled(); }); it("resets the backoff after healthy traffic", async () => { @@ -533,6 +579,7 @@ describe("event stream recovery", () => { failed.readyState = 2; failed.onerror?.({}); store.stop(); + expect(store.getSnapshot().reconnecting).toBe(false); await vi.advanceTimersByTimeAsync(60_000); expect(esInstances).toHaveLength(1); }); From 8f485ab8458c2c47667a3abbc9a0c1b7ba4c948e Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sun, 27 Sep 2026 00:08:24 +0800 Subject: [PATCH 3/3] fix(dsh-plugin): show observation outages on the collapsed capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded header already replaced a stale "s1 · clicking" line with "reconnecting…", but the collapsed capsule and both status dots still read the last session as live. Collapse is the form users leave on the page, so an outage there looked like an in-progress action. - treat reconnecting as its own chrome state so the header and capsule dots leave the active color - keep the capsule's session count and swap the action timer for "reconnecting…" until the feed delivers a frame again --- .../src/client/ObservationOverlay.tsx | 31 ++++++++++++++----- .../tests/client/observation-overlay.test.tsx | 28 +++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index 4de2b3fa..e75c07fd 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -239,14 +239,16 @@ function StopSessionAction(props: { ); } +type ChromeState = "active" | "idle" | "error" | "reconnecting"; + /** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */ -function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) { +function StatusDot({ state }: { state: ChromeState | "dead" }) { const color = state === "active" ? "bg-emerald-500" : state === "error" ? "bg-red-500" - : state === "dead" + : state === "dead" || state === "reconnecting" ? "bg-amber-500" : "bg-muted-foreground/40"; return ( @@ -390,7 +392,13 @@ export function OverlayBody(props: { : focus === undefined ? "no session" : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; - const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; + const state: ChromeState = reconnecting + ? "reconnecting" + : !available + ? "error" + : focus !== undefined + ? statusOf(focus) + : "idle"; return (
- + {statusText} {onUseFloating !== undefined ? ( setCollapsed(false)} > {snapshot.sessions.length} session{snapshot.sessions.length === 1 ? "" : "s"} - {focus !== undefined && focus.action !== "idle" - ? ` · ${focus.action} · ${formatElapsed(focus.since, now)}` - : ""} + {snapshot.reconnecting + ? " · reconnecting…" + : focus !== undefined && focus.action !== "idle" + ? ` · ${focus.action} · ${formatElapsed(focus.since, now)}` + : ""} ); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index 43595331..0a1a8399 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -228,13 +228,37 @@ describe("ObservationOverlay", () => { h.es().readyState = 0; h.es().onerror?.({}); }); - expect(screen.getByTestId("obs-header").textContent).toContain("reconnecting…"); + const header = screen.getByTestId("obs-header"); + expect(header.textContent).toContain("reconnecting…"); + expect(header.querySelector("[data-state]")?.getAttribute("data-state")).toBe("reconnecting"); expect(screen.queryByText(/s1 · clicking/)).toBeNull(); act(() => h.emitRaw({ type: "snapshot", sessions: [BUSY], available: true })); - expect(screen.getByTestId("obs-header").textContent).not.toContain("reconnecting"); + expect(header.textContent).not.toContain("reconnecting"); + expect(header.querySelector("[data-state]")?.getAttribute("data-state")).toBe("active"); expect(screen.getByText(/s1 · clicking/)).toBeTruthy(); }); + it("collapses to a capsule that drops stale action timing while reconnecting", async () => { + const h = makeHarness([BUSY]); + render(); + await screen.findByText(/s1 · clicking/); + fireEvent.click(screen.getByRole("button", { name: "Collapse" })); + const capsule = await screen.findByTestId("obs-capsule"); + expect(capsule.textContent).toContain("clicking"); + expect(capsule.getAttribute("data-state")).toBe("active"); + act(() => { + h.es().readyState = 0; + h.es().onerror?.({}); + }); + expect(capsule.textContent).toContain("reconnecting…"); + expect(capsule.textContent).not.toContain("clicking"); + expect(capsule.getAttribute("data-state")).toBe("reconnecting"); + expect(capsule.querySelector("[data-state]")?.getAttribute("data-state")).toBe("reconnecting"); + act(() => h.emitRaw({ type: "snapshot", sessions: [BUSY], available: true })); + expect(capsule.textContent).toContain("clicking"); + expect(capsule.getAttribute("data-state")).toBe("active"); + }); + it("shows the status row and a placeholder without a thumbnail", async () => { const h = makeHarness([BUSY]); render();