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
43 changes: 33 additions & 10 deletions src/hosted-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { presenceItemKey } from "./presence-identity.js";
// rule and default as Discord, #339) is uploaded as idle until it moves.

export function createHostedLoop({
getPresence, uploader, intervalMs = 15_000, failAfterMs = 60_000, stuckAfterMs = 300_000,
getPresence, uploader, intervalMs = 15_000, failAfterMs = 60_000, stuckAfterMs = 300_000, stallAfterMs = 60_000,
elapsedNow = () => performance.now(), setTimer = setTimeout, clearTimer = clearTimeout,
} = {}) {
if (typeof getPresence !== "function") throw new TypeError("getPresence is required");
if (typeof uploader?.push !== "function") throw new TypeError("uploader.push is required");
if (!Number.isInteger(intervalMs) || intervalMs < 1000) throw new RangeError("intervalMs must be at least 1000");
if (!Number.isInteger(failAfterMs) || failAfterMs < 0) throw new RangeError("failAfterMs is invalid");
if (!Number.isInteger(stuckAfterMs) || stuckAfterMs < 1000) throw new RangeError("stuckAfterMs must be at least 1000");
if (!Number.isInteger(stallAfterMs) || stallAfterMs < 1000) throw new RangeError("stallAfterMs must be at least 1000");
let timer = null;
let stopped = true;
let running = null;
Expand All @@ -43,6 +44,14 @@ export function createHostedLoop({
return elapsedNow() - stuck.since >= stuckAfterMs;
}

// Before a frozen position is stale enough to clear, it is already not
// playing (the player closed, or the server stopped updating): show it as
// paused at that position, as the Discord loop does (#1099). Never applies
// to a position that moved or to the first sight of an item.
function isStalled(presence) {
return Boolean(stuck) && presence.state === "playing" && elapsedNow() - stuck.since >= Math.min(stallAfterMs, stuckAfterMs - 1);
}

async function push(presence) {
try { return await uploader.push(presence); } catch { return { sent: false, reason: "upload_failed" }; }
}
Expand All @@ -53,27 +62,41 @@ export function createHostedLoop({
try {
presence = await getPresence();
} catch {
if (privacyClearPending) return clearForPrivacy();
failingSince ??= elapsedNow();
if (clearedForFailure || elapsedNow() - failingSince < failAfterMs) return { sent: false, reason: "provider_error" };
const result = await push(createPresence({ state: "idle" }));
// Only stop retrying once the idle state actually reached the host (or
// the uploader says it already has it).
if (result.sent || result.reason === "unchanged") clearedForFailure = true;
return { ...result, cleared: "provider_error" };
return providerFailed();
}
if (privacyClearPending) return clearForPrivacy();
failingSince = null;
clearedForFailure = false;
if (!presence) return { sent: false, reason: "no_presence" };
const frozen = await pushFrozen(presence);
if (frozen) return frozen;
if (privacyClearPending) return clearForPrivacy();
return push(presence);
}

async function providerFailed() {
if (privacyClearPending) return clearForPrivacy();
failingSince ??= elapsedNow();
if (clearedForFailure || elapsedNow() - failingSince < failAfterMs) return { sent: false, reason: "provider_error" };
const result = await push(createPresence({ state: "idle" }));
// Only stop retrying once the idle state actually reached the host (or
// the uploader says it already has it).
if (result.sent || result.reason === "unchanged") clearedForFailure = true;
return { ...result, cleared: "provider_error" };
}

// A position that stopped moving: paused after the stall window, idle after
// the stuck window. Returns null while the position is moving (or new).
async function pushFrozen(presence) {
if (isStuck(presence)) {
if (clearedForStuck) return { sent: false, reason: "stuck" };
const result = await push(createPresence({ state: "idle" }));
if (result.sent || result.reason === "unchanged") clearedForStuck = true;
return { ...result, cleared: "stuck" };
}
if (privacyClearPending) return clearForPrivacy();
return push(presence);
if (isStalled(presence)) return { ...(await push({ ...presence, state: "paused" })), stalled: true };
return null;
}

function clearForPrivacy() {
Expand Down
24 changes: 22 additions & 2 deletions test/hosted-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ test("a playing session frozen at one position is uploaded as idle after the Dis
await env.loop.tick();
env.advance(299_000);
await env.loop.tick();
assert.equal(env.pushed.at(-1).state, "playing");
assert.equal(env.pushed.at(-1).state, "paused");
env.advance(1_000);
assert.equal((await env.loop.tick()).cleared, "stuck");
assert.equal(env.pushed.at(-1).state, "idle");
Expand Down Expand Up @@ -206,7 +206,7 @@ test("frozen playback clears after five elapsed minutes despite a wall-clock rol
assert.equal((await env.loop.tick()).cleared, "stuck");
env.advance(60_000);
assert.equal((await env.loop.tick()).reason, "stuck");
assert.deepEqual(env.pushed.map((p) => p.state), ["playing", "playing", "playing", "playing", "playing", "idle"]);
assert.deepEqual(env.pushed.map((p) => p.state), ["playing", "paused", "paused", "paused", "paused", "idle"]);
env.set(playingAt(43_000));
assert.notEqual((await env.loop.tick()).cleared, "stuck");
assert.equal(env.pushed.at(-1).state, "playing");
Expand Down Expand Up @@ -253,3 +253,23 @@ test("normal playback and paused sessions are never treated as stuck", async ()
for (let i = 0; i < 30; i += 1) { env.advance(15_000); await env.loop.tick(); }
assert.equal(env.pushed.some((p) => p.state === "idle"), false);
});

test("a playing session frozen for the stall window is uploaded as paused at that position (#1100)", async () => {
const env = staleSetup({ presence: playingAt(42_000) });
await env.loop.tick();
env.advance(15_000);
assert.equal((await env.loop.tick()).stalled, undefined, "15s without movement is not stalled");
assert.equal(env.pushed.at(-1).state, "playing");
env.advance(45_000);
assert.equal((await env.loop.tick()).stalled, true);
assert.equal(env.pushed.at(-1).state, "paused");
assert.equal(env.pushed.at(-1).positionMs, 42_000);
env.set(playingAt(43_000));
env.advance(15_000);
assert.equal((await env.loop.tick()).stalled, undefined);
assert.equal(env.pushed.at(-1).state, "playing", "playback that moves again is playing");
});

test("stallAfterMs is validated", () => {
assert.throws(() => createHostedLoop({ getPresence() {}, uploader: { push() {} }, stallAfterMs: 10 }), RangeError);
});
Loading