fix stuck replayer bug
Some checks failed
DB migrations are backwards-compatible / Check if migrations changed (push) Has been cancelled
DB migrations are backwards-compatible / Test migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migrations are backwards-compatible / No migration changes (skipped) (push) Has been cancelled

This commit is contained in:
Bilal Godil 2026-02-13 10:16:40 -08:00
parent 5868a35e6e
commit fa3242e504
3 changed files with 883 additions and 22 deletions

View File

@ -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) {

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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,