Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions tests/browser/replay-feed.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Run with: node --test tests/browser/replay-feed.test.js
//
// The replay feed's answer to a 429, run rather than read: a fixed one-second
// retry passes every text check and still asks the server again sixty times
// inside the window it named.

import { test } from "node:test";
import assert from "node:assert/strict";

const replayFeed = await import("../../web/replay-feed.js");

function fakeClock() {
const real = { now: Date.now, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
let now = 0;
let nextId = 1;
const pending = new Map();
Date.now = () => now;
globalThis.setTimeout = (fn, ms) => {
pending.set(nextId, { at: now + ms, fn });
return nextId++;
};
globalThis.clearTimeout = (id) => pending.delete(id);
return {
tick(ms) {
now += ms;
for (const [id, timer] of [...pending]) {
if (timer.at > now) continue;
pending.delete(id);
timer.fn();
}
},
restore() {
Date.now = real.now;
globalThis.setTimeout = real.setTimeout;
globalThis.clearTimeout = real.clearTimeout;
},
};
}

test("replay-feed waits out Retry-After", async () => {
const clock = fakeClock();
const posts = [];
const statuses = [429, 204];
globalThis.fetch = async (_url, init) => {
posts.push(JSON.parse(init.body).events.length);
const status = statuses.shift();
return { status, headers: { get: (name) => (name === "Retry-After" ? "30" : null) } };
};
try {
replayFeed.initReplay({
state: { interviewId: "i1" },
nodes: {},
recordingEnabled: true,
consentVersion: "v1",
replayVersion: 1,
});
replayFeed.recordReplay("lifecycle", { state: "started" });
await replayFeed.flushReplay();
assert.deepEqual(posts, [1], "the first batch is refused");

replayFeed.recordReplay("lifecycle", { state: "still_here" });
await replayFeed.flushReplay();
assert.deepEqual(posts, [1], "a flush inside the window sends nothing");

for (let second = 1; second < 30; second += 1) {
clock.tick(1000);
await new Promise(setImmediate);
}
assert.deepEqual(posts, [1], "nor does any timer before the window has passed");

clock.tick(1000);
await new Promise(setImmediate);
assert.deepEqual(posts, [1, 2], "then the kept batch goes, with what queued behind it");
Comment thread
ColtenOuO marked this conversation as resolved.
} finally {
replayFeed.closeReplay();
clock.restore();
delete globalThis.fetch;
}
});
8 changes: 5 additions & 3 deletions web/interview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1473,9 +1473,11 @@ function endInterview(reason) {
// who ends while it is still up would read the report status through it.
globalThis.clearTimeout(frameworkHintTimer);
nodes.frameworkHint.hidden = true;
// Last event, and sent rather than queued: the page is about to stop being
// the kind of page that flushes timers, and an "ended" nobody sent leaves a
// replay that just stops.
// Last event, and flushed now rather than left to the batching timer: the
// page is about to stop being the kind of page that flushes timers, and an
// "ended" nobody sent leaves a replay that just stops. Inside a Retry-After
// window the flush sends nothing and the event still waits on that timer,
// so a tab closed before the window passes loses it.
recordReplay("lifecycle", { state: "ended", reason });
void flushReplay();
// The end_interview payload carries the final buffer, so drop any debounced
Expand Down
20 changes: 16 additions & 4 deletions web/replay-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ export function recordReplay(kind, payload) {
void flushReplay();
return;
}
replayTimer ||= setTimeout(() => void flushReplay(), REPLAY_FLUSH_MS);
scheduleFlush();
}

function scheduleFlush() {
if (replayTimer) return;
const delay = Math.max(REPLAY_FLUSH_MS, retryAfter - Date.now());
replayTimer = setTimeout(() => void flushReplay(), delay);
}

/// How long to wait after the server says it has heard enough for now.
Expand Down Expand Up @@ -91,8 +97,10 @@ export function closeReplay() {
/// that batch is kept and sent when the minute is up.
/// One flush at a time, in the order they were asked for.
///
/// `recordReplay` starts one whenever the queue fills, and the interview's last
/// act awaits one. Left unserialized, a batch posted while another was still
/// `recordReplay` starts one whenever the queue fills, and the interview's end
/// asks for one without waiting on it. Inside a Retry-After window a flush
/// resolves without posting and leaves the queue to the timer that window
/// armed. Left unserialized, a batch posted while another was still
/// awaiting `fetch` could commit first, and the replay would be ordered by
/// whichever request the server happened to finish rather than by what the
/// candidate did. Chained rather than skipped, because a caller that is told
Expand All @@ -108,6 +116,10 @@ export function flushReplay() {

async function sendQueuedBatch() {
if (!replayQueue.length || replayClosed) return;
if (Date.now() < retryAfter) {
scheduleFlush();
return;
}
Comment thread
ColtenOuO marked this conversation as resolved.
const batch = replayQueue.splice(0, REPLAY_MAX_BATCH);
try {
const response = await fetch(`/api/interviews/${encodeURIComponent(state.interviewId)}/events`, {
Expand Down Expand Up @@ -154,7 +166,7 @@ async function sendQueuedBatch() {
} catch {
// Offline. The interview is what matters and it is still running.
}
if (replayQueue.length) replayTimer ||= setTimeout(() => void flushReplay(), REPLAY_FLUSH_MS);
if (replayQueue.length) scheduleFlush();
}

/// The problem heading, as the recording shows it.
Expand Down