From fa3242e5042880270fb34955baf4473fda769ad9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 13 Feb 2026 10:16:40 -0800 Subject: [PATCH] fix stuck replayer bug --- .../analytics/replays/page-client.tsx | 27 +- .../replays/session-replay-machine.test.ts | 573 ++++++++++++++++++ .../replays/session-replay-machine.ts | 305 +++++++++- 3 files changed, 883 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 908bc8f20..945e84e23 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -483,7 +483,25 @@ export default function PageClient() { return; } - if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) return; + if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) { + // Last-resort: scan accumulated events for a FullSnapshot that the + // chunk-level detection may have missed (eg. due to race conditions or + // type coercion). rrweb FullSnapshot is event type 2. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hasSnapshot = eventsSnapshot.some(e => (e as any).type === 2 || (e as any).type === "2"); + if (!hasSnapshot) return; + // Patch the machine state so subsequent checks pass. + actRef.current({ + type: "CHUNK_LOADED", + generation: gen, + tabKey, + hasFullSnapshot: true, + loadedDurationMs: eventsSnapshot.length >= 2 + ? eventsSnapshot[eventsSnapshot.length - 1].timestamp - eventsSnapshot[0].timestamp + : 0, + hadEventsBeforeThisChunk: true, + }); + } try { const { Replayer } = await import("rrweb"); @@ -617,6 +635,11 @@ export default function PageClient() { } catch { // ignore } + } else { + // Replayer doesn't exist — try to create it so REPLAYER_READY + // can resume playback. This covers race conditions where the + // replayer hasn't been initialised yet when play is requested. + runAsynchronously(() => ensureReplayerForTab(effect.tabKey, msRef.current.generation), { noErrorLogging: true }); } break; } @@ -798,7 +821,7 @@ export default function PageClient() { const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - && events.some(e => (e as any).type === 2); + && events.some(e => Number((e as any).type) === 2); let loadedDurationMs = 0; if (prev.length >= 2) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts index 4f75fce0a..889b7ec32 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts @@ -7,6 +7,7 @@ import { findNextTabStartAfterGlobalOffset, ALLOWED_PLAYER_SPEEDS, DEFAULT_REPLAY_SETTINGS, + STALL_THRESHOLD_MS, type ReplayState, type ReplayAction, type StreamInfo, @@ -253,6 +254,64 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(true); }); + it("switches active tab when current active has no snapshot", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Active tab "a" has no full snapshot + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.replayerReady.delete("b"); + state.hasFullSnapshotByTab.delete("b"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 500, + hadEventsBeforeThisChunk: false, + }); + expect(s.activeTabKey).toBe("b"); + }); + + it("does NOT switch active tab when active already has a snapshot", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Active tab "a" already has full snapshot + state.replayerReady.delete("b"); + state.hasFullSnapshotByTab.delete("b"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 500, + hadEventsBeforeThisChunk: false, + }); + expect(s.activeTabKey).toBe("a"); + }); + + it("does NOT switch on subsequent snapshots for same tab", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Tab "b" already has a full snapshot + // Active tab "a" has no full snapshot + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 2000, + hadEventsBeforeThisChunk: true, + }); + // Tab b already had a snapshot, so no switch needed + expect(s.activeTabKey).toBe("a"); + }); + it("stays buffering when not enough data", () => { const state = twoTabReadyState({ playbackMode: "buffering", @@ -306,6 +365,27 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(false); }); + it("auto-plays non-active tab when active tab is stuck (no full snapshot)", () => { + const state = twoTabReadyState({ + autoPlayTriggered: false, + activeTabKey: "a", + }); + // Active tab "a" has no full snapshot — it's stuck + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.replayerReady.delete("b"); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "b", + }); + // Should switch to "b" and auto-play + expect(s.activeTabKey).toBe("b"); + expect(s.autoPlayTriggered).toBe(true); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + it("does not auto-play when already triggered", () => { const state = twoTabReadyState({ autoPlayTriggered: true, @@ -509,6 +589,21 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(true); }); + it("switches to a ready tab when active tab has no replayer", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + pausedAtGlobalMs: 0, + }); + // Active tab "a" has no full snapshot — can't play + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + it("buffers when trying to play beyond loaded data", () => { const state = twoTabReadyState({ playbackMode: "paused", @@ -523,6 +618,32 @@ describe("session-replay-machine", () => { expect(s.playbackMode).toBe("buffering"); expect(s.autoResumeAfterBuffering).toBe(true); }); + + it("emits schedule_buffer_poll when entering buffering (no snapshot)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "downloading", + }); + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); + + it("emits schedule_buffer_poll when entering buffering (data not loaded)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + pausedAtGlobalMs: 3500, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 2000); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); }); describe("SEEK", () => { @@ -779,6 +900,26 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); }); + it("does not resume to playing when active tab has no snapshot", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + phase: "ready", + }); + // Tab a has no full snapshot — can't play + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.loadedDurationByTabMs.set("a", 5000); + const { state: s, effects } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 1, + tabKey: "a", + }); + expect(s.playbackMode).toBe("paused"); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + it("ignores stale generation", () => { const state = twoTabReadyState({ playbackMode: "buffering" }); const { effects } = dispatch(state, { @@ -809,6 +950,51 @@ describe("session-replay-machine", () => { expect(s.bufferingAtGlobalMs).toBeNull(); expect(hasEffect(effects, "play_replayer")).toBe(true); }); + + it("switches to alt tab when active tab cannot play", () => { + const state: ReplayState = { + ...twoTabReadyState({ + phase: "downloading", + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + activeTabKey: "a", + }), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 1000, lastMs: 5000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[1000, 5000]], + }), + }; + // Tab a has no snapshot or replayer, but tab b does + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.phase).toBe("ready"); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("pauses when no tab can play on download complete", () => { + const state = twoTabReadyState({ + phase: "downloading", + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + activeTabKey: "a", + }); + // No tab has snapshot or replayer + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.phase).toBe("ready"); + expect(s.playbackMode).toBe("paused"); + }); }); describe("DOWNLOAD_ERROR", () => { @@ -1274,6 +1460,393 @@ describe("scenarios", () => { }); }); +// --------------------------------------------------------------------------- +// Stall detection tests +// --------------------------------------------------------------------------- + +describe("stall detection", () => { + it("starts tracking when playing with null activeReplayerLocalTimeMs", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBe(5000); + }); + + it("resets tracker when replayer starts responding", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 2000, + activeReplayerLocalTimeMs: 500, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker when playback mode is not playing", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("does not trigger recovery before threshold", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 5000, + }); + // 2999ms stall — just under threshold + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000 + STALL_THRESHOLD_MS - 1, + activeReplayerLocalTimeMs: null, + }); + // Should still be tracking, no recovery + expect(s.playingWithoutProgressSinceMs).toBe(5000); + expect(s.playbackMode).toBe("playing"); + }); + + it("Strategy A: switches to another ready tab on stall", () => { + // Tab a is active but stalled, tab b is ready and in range + const state: ReplayState = { + ...twoTabReadyState(), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 1000, lastMs: 5000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[1000, 5000]], + }), + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }; + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.activeTabKey).toBe("b"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("Strategy B: recreates replayer when active tab is ready but broken", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Only tab a is in range at offset 2000, and it's in replayerReady + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.replayerReady.has("a")).toBe(false); + expect(hasEffect(effects, "recreate_replayer")).toBe(true); + const recreateEffects = getEffects(effects, "recreate_replayer"); + expect((recreateEffects[0] as any).tabKey).toBe("a"); + }); + + it("Strategy C: ensures replayer when tab has snapshot but no replayer", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Tab a has full snapshot but is NOT in replayerReady + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + const ensureEffects = getEffects(effects, "ensure_replayer"); + expect((ensureEffects[0] as any).tabKey).toBe("a"); + }); + + it("Strategy D: switches to any ready tab at a different offset", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Tab a has no snapshot and is not ready, but tab b IS ready + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("Strategy E: pauses with error when nothing works (download complete)", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + phase: "ready", + }); + // No tab has snapshot or replayer — nothing can recover + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playbackMode).toBe("paused"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.playerError).toContain("stalled"); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("Strategy E: buffers instead of error when still downloading", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + phase: "downloading", + }); + // No tab has snapshot or replayer — but still downloading + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playbackMode).toBe("buffering"); + expect(s.bufferingAtGlobalMs).toBe(2000); + expect(s.autoResumeAfterBuffering).toBe(true); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.playerError).toBeNull(); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("TOGGLE_PLAY_PAUSE stays paused with error when no tab can play (download complete)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "ready", + }); + // No tab has snapshot or replayer — nothing can play + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toContain("Unable to play"); + }); + + it("TOGGLE_PLAY_PAUSE stays paused with error when activeTabKey is null", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: null, + phase: "ready", + }); + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toContain("Unable to play"); + }); + + it("TOGGLE_PLAY_PAUSE buffers when no tab can play but still downloading", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "downloading", + }); + // No tab has snapshot or replayer yet — data still arriving + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(s.autoResumeAfterBuffering).toBe(true); + expect(s.playerError).toBeNull(); + }); + + it("resets tracker on TOGGLE_PLAY_PAUSE (pause)", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on TOGGLE_PLAY_PAUSE (play)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on SEEK", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on SELECT_TAB", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on REPLAYER_READY", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + state.replayerReady.delete("a"); + const { state: s } = dispatch(state, { type: "REPLAYER_READY", generation: 1, tabKey: "a" }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("initializes tracker as null", () => { + const state = createInitialState(); + expect(state.playingWithoutProgressSinceMs).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// playerError clearing tests +// --------------------------------------------------------------------------- + +describe("playerError clearing", () => { + it("TOGGLE_PLAY_PAUSE (play) clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("playing"); + expect(s.playerError).toBeNull(); + }); + + it("TOGGLE_PLAY_PAUSE (pause) clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + playerError: "Some error", + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toBeNull(); + }); + + it("SEEK clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 1000 }); + expect(s.playerError).toBeNull(); + }); + + it("SELECT_TAB clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.playerError).toBeNull(); + }); + + it("CHUNK_LOADED with hasFullSnapshot on active tab clears playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBeNull(); + }); + + it("CHUNK_LOADED on non-active tab does NOT clear playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBe("Unable to play: recording data may be incomplete."); + }); + + it("CHUNK_LOADED without hasFullSnapshot on active tab does NOT clear playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: false, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBe("Unable to play: recording data may be incomplete."); + }); +}); + // --------------------------------------------------------------------------- // Invariant tests (fuzz random action sequences) // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts index 65522ac46..2d3ec8080 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts @@ -17,6 +17,10 @@ import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; export const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); +/** How long (wall-clock ms) the player can be in "playing" mode without the + * replayer reporting progress before we attempt automatic recovery. */ +export const STALL_THRESHOLD_MS = 3000; + export const DEFAULT_REPLAY_SETTINGS: ReplaySettings = { playerSpeed: 1, skipInactivity: true, @@ -93,6 +97,10 @@ export type ReplayState = { * position because `addEvent` didn't extend the playable range. */ prematureFinishRetryLocalMs: number | null, + /** Wall-clock time when we first noticed "playing" mode but the replayer + * hadn't reported any progress. Used for stall detection. */ + playingWithoutProgressSinceMs: number | null, + downloadError: string | null, playerError: string | null, }; @@ -194,6 +202,7 @@ export function createInitialState(settings?: ReplaySettings): ReplayState { bufferingAtGlobalMs: null, gapFastForward: null, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, downloadError: null, playerError: null, }; @@ -378,22 +387,36 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; const effects: ReplayEffect[] = []; - let newPlaybackMode = state.playbackMode; + let newPlaybackMode: PlaybackMode = state.playbackMode === "buffering" ? "paused" : state.playbackMode; + let newActiveTabKey = state.activeTabKey; - // Safety net: if buffering when download finishes, resume + // Safety net: if buffering when download finishes, try to resume if (state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering) { const seekTo = state.bufferingAtGlobalMs; - newPlaybackMode = "playing"; - effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, seekTo)); + let resumeTabKey = state.activeTabKey; + + // Verify the active tab can actually play + if (resumeTabKey && !state.replayerReady.has(resumeTabKey) && !state.hasFullSnapshotByTab.has(resumeTabKey)) { + resumeTabKey = findBestTabAtGlobalOffset(state, seekTo); + } + + if (resumeTabKey && (state.replayerReady.has(resumeTabKey) || state.hasFullSnapshotByTab.has(resumeTabKey))) { + newPlaybackMode = "playing"; + newActiveTabKey = resumeTabKey; + if (resumeTabKey !== state.activeTabKey) { + effects.push({ type: "ensure_replayer", tabKey: resumeTabKey, generation: state.generation }); + } + effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: resumeTabKey }, seekTo)); + } + // else: newPlaybackMode stays "paused" — no tab can play } return { state: { ...state, phase: "ready", - playbackMode: state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering - ? "playing" - : (state.playbackMode === "buffering" ? "paused" : state.playbackMode), + activeTabKey: newActiveTabKey, + playbackMode: newPlaybackMode, bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, }, @@ -436,6 +459,19 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: action.generation }); } + // If the active tab has no FullSnapshot but this tab just got one, switch. + // This ensures the component renders a container for the playable tab. + let newActiveTabKey = state.activeTabKey; + if ( + action.hasFullSnapshot + && !state.hasFullSnapshotByTab.has(action.tabKey) + && state.activeTabKey !== null + && state.activeTabKey !== action.tabKey + && !newHasFullSnapshot.has(state.activeTabKey) + ) { + newActiveTabKey = action.tabKey; + } + // Check if buffering can be resolved by new data let newPlaybackMode = state.playbackMode; let newBufferingAtGlobalMs = state.bufferingAtGlobalMs; @@ -443,7 +479,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer let newPausedAtGlobalMs = state.pausedAtGlobalMs; if ( - state.activeTabKey === action.tabKey + newActiveTabKey === action.tabKey && state.bufferingAtGlobalMs !== null ) { const stream = getStreamInfo(state, action.tabKey); @@ -463,7 +499,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer newPlaybackMode = "playing"; newPausedAtGlobalMs = seekTo; effects.push(...playEffectsForAllTabs( - { ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, + { ...state, playbackMode: "playing", activeTabKey: newActiveTabKey }, seekTo, )); } else { @@ -473,9 +509,13 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer } } + // Clear playerError when the active tab receives a FullSnapshot + const clearPlayerError = action.hasFullSnapshot && action.tabKey === newActiveTabKey; + return { state: { ...state, + activeTabKey: newActiveTabKey, hasFullSnapshotByTab: newHasFullSnapshot, loadedDurationByTabMs: newLoadedDuration, tabsWithEvents: newTabsWithEvents, @@ -483,6 +523,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: newBufferingAtGlobalMs, autoResumeAfterBuffering: newAutoResumeAfterBuffering, pausedAtGlobalMs: newPausedAtGlobalMs, + ...(clearPlayerError ? { playerError: null } : {}), }, effects, }; @@ -495,13 +536,28 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer newReplayerReady.add(action.tabKey); const isActiveTab = state.activeTabKey === action.tabKey; - const shouldAutoPlay = !state.autoPlayTriggered && isActiveTab; - const shouldPlay = isActiveTab && (shouldAutoPlay || (state.playbackMode === "playing")); + + // Auto-play fallback: if auto-play hasn't triggered yet and the active + // tab is stuck (no full snapshot → replayer can never be created), switch + // the active tab to this newly-ready tab so auto-play can proceed. + const activeTabStuck = !isActiveTab + && !state.autoPlayTriggered + && state.activeTabKey !== null + && !state.hasFullSnapshotByTab.has(state.activeTabKey); + const effectiveIsActiveTab = isActiveTab || activeTabStuck; + + const shouldAutoPlay = !state.autoPlayTriggered && effectiveIsActiveTab; + const shouldPlay = effectiveIsActiveTab && (shouldAutoPlay || (state.playbackMode === "playing")); + + const newActiveTabKey = activeTabStuck ? action.tabKey : state.activeTabKey; const effects: ReplayEffect[] = []; const stream = getStreamInfo(state, action.tabKey); const streamStartTs = stream?.firstEventAtMs ?? state.globalStartTs; - const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, state.pausedAtGlobalMs); + const targetGlobalMs = activeTabStuck + ? localOffsetToGlobalOffset(state.globalStartTs, streamStartTs, 0) + : state.pausedAtGlobalMs; + const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, targetGlobalMs); if (shouldPlay) { effects.push({ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: desiredLocal }); @@ -512,11 +568,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer return { state: { ...state, + activeTabKey: newActiveTabKey, replayerReady: newReplayerReady, autoPlayTriggered: state.autoPlayTriggered || shouldAutoPlay, playbackMode: shouldAutoPlay && state.playbackMode !== "buffering" ? "playing" : state.playbackMode, + pausedAtGlobalMs: activeTabStuck ? targetGlobalMs : state.pausedAtGlobalMs, + playingWithoutProgressSinceMs: null, }, effects, }; @@ -555,13 +614,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer ...state, replayerReady: newReplayerReady, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, }, effects: [{ type: "recreate_replayer", tabKey: action.tabKey, generation: action.generation }], }; } return { - state: { ...state, prematureFinishRetryLocalMs: localTime }, + state: { ...state, prematureFinishRetryLocalMs: localTime, playingWithoutProgressSinceMs: null }, effects: [{ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: localTime }], }; } @@ -586,6 +646,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer pausedAtGlobalMs: globalOffset, bufferingAtGlobalMs: globalOffset, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, }, effects: [ { type: "schedule_buffer_poll", generation: action.generation, tabKey: action.tabKey, localTimeMs: localTime, delayMs: 500 }, @@ -622,6 +683,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, suppressAutoFollowUntilWallMs: action.nowMs + 400, + playingWithoutProgressSinceMs: null, }, effects, }; @@ -654,6 +716,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer playbackMode: "gap_fast_forward", gapFastForward: gff, pausedAtGlobalMs: globalOffset, + playingWithoutProgressSinceMs: null, }, effects: [], }; @@ -667,6 +730,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer pausedAtGlobalMs: globalOffset, bufferingAtGlobalMs: globalOffset, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, }, effects: [], }; @@ -681,6 +745,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer currentGlobalTimeMsForUi: state.globalTotalMs, gapFastForward: null, bufferingAtGlobalMs: null, + playingWithoutProgressSinceMs: null, }, effects: [{ type: "pause_all" }], }; @@ -699,43 +764,109 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer gapFastForward: null, bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects: [{ type: "pause_all" }], }; } // Play - const target = state.pausedAtGlobalMs; + let target = state.pausedAtGlobalMs; + let playActiveTabKey = state.activeTabKey; + + // If active tab has no replayer and can't get one, switch to a tab that can play + if (playActiveTabKey && !state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey)) { + const altTab = findBestTabAtGlobalOffset(state, target); + if (altTab) { + playActiveTabKey = altTab; + } else { + // Find any ready tab at its start time + for (const s of state.streams) { + if (state.replayerReady.has(s.tabKey)) { + playActiveTabKey = s.tabKey; + target = localOffsetToGlobalOffset(state.globalStartTs, s.firstEventAtMs, 0); + break; + } + } + } + } + + // Guard: if no tab can play, either buffer (still downloading) or error + if ( + !playActiveTabKey + || (!state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey)) + ) { + if (state.phase === "downloading" && playActiveTabKey) { + // Data may still arrive — enter buffering mode + const bufferStream = getStreamInfo(state, playActiveTabKey); + const bufferLocalMs = bufferStream ? globalOffsetToLocalOffset(state.globalStartTs, bufferStream.firstEventAtMs, target) : 0; + return { + state: { + ...state, + activeTabKey: playActiveTabKey, + pausedAtGlobalMs: target, + playbackMode: "buffering", + bufferingAtGlobalMs: target, + autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + playerError: null, + }, + effects: [ + { type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: bufferLocalMs, delayMs: 500 }, + ], + }; + } + return { + state: { + ...state, + playbackMode: "paused", + playerError: "Unable to play: recording data may be incomplete. Try reloading.", + playingWithoutProgressSinceMs: null, + }, + effects: [], + }; + } // Check if active tab needs buffering - if (state.phase === "downloading" && state.activeTabKey) { - const stream = getStreamInfo(state, state.activeTabKey); + if (state.phase === "downloading" && playActiveTabKey) { + const stream = getStreamInfo(state, playActiveTabKey); if (stream) { const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, target); - const loaded = state.loadedDurationByTabMs.get(state.activeTabKey) ?? 0; + const loaded = state.loadedDurationByTabMs.get(playActiveTabKey) ?? 0; if (localTarget > loaded) { return { state: { ...state, + activeTabKey: playActiveTabKey, + pausedAtGlobalMs: target, playbackMode: "buffering", bufferingAtGlobalMs: target, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + playerError: null, }, - effects: [], + effects: [ + { type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: localTarget, delayMs: 500 }, + ], }; } } } + const stateForPlay = { ...state, activeTabKey: playActiveTabKey }; return { state: { - ...state, + ...stateForPlay, playbackMode: "playing", + pausedAtGlobalMs: target, bufferingAtGlobalMs: null, gapFastForward: null, suppressAutoFollowUntilWallMs: action.nowMs + 400, + playingWithoutProgressSinceMs: null, + playerError: null, }, - effects: playEffectsForAllTabs(state, target), + effects: playEffectsForAllTabs(stateForPlay, target), }; } @@ -770,6 +901,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: action.globalOffsetMs, autoResumeAfterBuffering: true, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -790,6 +923,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer currentGlobalTimeMsForUi: action.globalOffsetMs, suppressAutoFollowUntilWallMs: action.nowMs + 400, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -817,6 +952,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer autoResumeAfterBuffering: true, suppressAutoFollowUntilWallMs: action.nowMs + 5000, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -840,6 +977,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer autoResumeAfterBuffering: false, suppressAutoFollowUntilWallMs: action.nowMs + 5000, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -944,6 +1083,101 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer } } + // ----- Stall detection ----- + // Track when the player is in "playing" mode but the replayer hasn't + // reported any progress (activeReplayerLocalTimeMs is null). + if (newState.playbackMode === "playing" && action.activeReplayerLocalTimeMs !== null) { + // Replayer is responding — clear tracker + newState = { ...newState, playingWithoutProgressSinceMs: null }; + } else if (newState.playbackMode !== "playing") { + // Not playing — clear tracker + newState = { ...newState, playingWithoutProgressSinceMs: null }; + } else if (action.activeReplayerLocalTimeMs === null) { + if (newState.playingWithoutProgressSinceMs === null) { + // Start timing the stall + newState = { ...newState, playingWithoutProgressSinceMs: action.nowMs }; + } else if (action.nowMs - newState.playingWithoutProgressSinceMs >= STALL_THRESHOLD_MS) { + // Stall detected — attempt recovery + const stallGlobalOffset = newState.pausedAtGlobalMs; + + // Strategy A: Switch to another tab that IS ready + const altTab = findBestTabAtGlobalOffset(newState, stallGlobalOffset, newState.activeTabKey ?? undefined); + if (altTab && newState.replayerReady.has(altTab)) { + newState = { + ...newState, + activeTabKey: altTab, + playingWithoutProgressSinceMs: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }; + effects.push( + ...playEffectsForAllTabs(newState, stallGlobalOffset), + ); + return { state: newState, effects }; + } + + // Strategy B: Active tab IS in replayerReady (but broken) — recreate + const activeKeyB = newState.activeTabKey; + if (activeKeyB && newState.replayerReady.has(activeKeyB)) { + const newReplayerReady = new Set(newState.replayerReady); + newReplayerReady.delete(activeKeyB); + newState = { + ...newState, + replayerReady: newReplayerReady, + playingWithoutProgressSinceMs: null, + }; + effects.push({ type: "recreate_replayer", tabKey: activeKeyB, generation: newState.generation }); + return { state: newState, effects }; + } + + // Strategy C: Tab has full snapshot but no replayer — ensure it + const activeKeyC = newState.activeTabKey; + if (activeKeyC && newState.hasFullSnapshotByTab.has(activeKeyC)) { + newState = { ...newState, playingWithoutProgressSinceMs: null }; + effects.push({ type: "ensure_replayer", tabKey: activeKeyC, generation: newState.generation }); + return { state: newState, effects }; + } + + // Strategy D: Switch to ANY ready tab (even at a different offset) + for (const s of newState.streams) { + if (s.tabKey === newState.activeTabKey) continue; + if (!newState.replayerReady.has(s.tabKey)) continue; + const altGlobalMs = localOffsetToGlobalOffset(newState.globalStartTs, s.firstEventAtMs, 0); + newState = { + ...newState, + activeTabKey: s.tabKey, + pausedAtGlobalMs: altGlobalMs, + currentGlobalTimeMsForUi: altGlobalMs, + playingWithoutProgressSinceMs: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }; + effects.push(...playEffectsForAllTabs(newState, altGlobalMs)); + return { state: newState, effects }; + } + + // Strategy E: Nothing works + if (state.phase === "downloading") { + // Still downloading — enter buffering, FullSnapshot may arrive + newState = { + ...newState, + playbackMode: "buffering", + bufferingAtGlobalMs: stallGlobalOffset, + autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + }; + effects.push({ type: "pause_all" }); + return { state: newState, effects }; + } + newState = { + ...newState, + playbackMode: "paused", + playingWithoutProgressSinceMs: null, + playerError: "Playback stalled: unable to recover. Try seeking or switching tabs.", + }; + effects.push({ type: "pause_all" }); + return { state: newState, effects }; + } + } + return { state: newState, effects }; } @@ -961,6 +1195,37 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer if (loaded > localTarget + 2000 || state.phase !== "downloading") { const seekTo = state.bufferingAtGlobalMs; + + // Verify the active tab can actually play before resuming + if (!state.replayerReady.has(action.tabKey) && !state.hasFullSnapshotByTab.has(action.tabKey)) { + const altTab = findBestTabAtGlobalOffset(state, seekTo); + if (altTab) { + return { + state: { + ...state, + activeTabKey: altTab, + playbackMode: "playing", + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: [ + { type: "ensure_replayer", tabKey: altTab, generation: action.generation }, + ...playEffectsForAllTabs({ ...state, activeTabKey: altTab }, seekTo), + ], + }; + } + // No tab can play — fall back to paused + return { + state: { + ...state, + playbackMode: "paused", + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: [{ type: "pause_all" }], + }; + } + return { state: { ...state,