fix(sync): bound request lifetime

req: operations/003
This commit is contained in:
slhx agent
2026-07-13 21:16:07 +02:00
parent 6d502d8ff5
commit 2a8aa4c07f
3 changed files with 118 additions and 6 deletions
+40 -3
View File
@@ -7,6 +7,7 @@ const LEGACY_COMMAND_SCHEMA = 1;
const MIGRATION_KEY = "commandSchemaMigration";
const MAX_ATTEMPTS = 3;
const BACKOFF_MS = [25, 50];
const REQUEST_TIMEOUT_MS = 1_000;
const root = document.querySelector("[data-kanban-sync]");
const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID();
const LEASE_MS = 5000;
@@ -16,6 +17,8 @@ let accountPartition;
let uploadLimit;
let retryTimer;
let leaseTimer;
let acknowledgementSource;
const activeRequests = new Set();
let synchronizing = false;
let uploadsThisRun = 0;
let uploadedTotal = 0;
@@ -39,6 +42,30 @@ class UploadError extends Error {
}
}
// req: operations/003
export async function fetchWithTimeout(
input,
init = {},
fetchImplementation = fetch,
timeoutMs = REQUEST_TIMEOUT_MS,
) {
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
throw new TypeError("sync request timeout must be a positive integer");
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(new DOMException(`sync request timed out after ${timeoutMs} ms`, "TimeoutError")),
timeoutMs,
);
activeRequests.add(controller);
try {
return await fetchImplementation(input, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
activeRequests.delete(controller);
}
}
function requestResult(request) {
return new Promise((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
@@ -322,7 +349,7 @@ async function upload(command) {
setPhase(attempt === 1 ? "uploading" : "retrying", `Uploading ${command.id} (attempt ${attempt} of ${MAX_ATTEMPTS}).`);
try {
const query = new URLSearchParams({ command_id: command.id, card_id: command.cardId, column: command.targetColumn });
const response = await fetch(`/sync/commands?${query}`, { method: "POST" });
const response = await fetchWithTimeout(`/sync/commands?${query}`, { method: "POST" });
if (response.status === 503 && attempt < MAX_ATTEMPTS) {
const base = BACKOFF_MS[attempt - 1];
const delay = base + Math.floor(Math.random() * base);
@@ -413,6 +440,7 @@ async function synchronize(command) {
const reconnect = command.session || command.actor || "kanban";
const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`);
acknowledgementSource = source;
let opens = 0;
source.addEventListener("open", () => {
opens += 1;
@@ -422,6 +450,7 @@ async function synchronize(command) {
const canonical = JSON.parse(event.data);
if (canonical.commandId !== command.id) return;
source.close();
if (acknowledgementSource === source) acknowledgementSource = undefined;
root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length));
const queueCommandId = command.queueCommandId || command.id;
await removePendingCommand(database, queueCommandId);
@@ -454,7 +483,7 @@ async function synchronize(command) {
});
source.addEventListener("snapshot-required", async (event) => {
const missing = JSON.parse(event.data);
const response = await fetch(missing.snapshotUrl);
const response = await fetchWithTimeout(missing.snapshotUrl);
if (!response.ok) throw new Error(`snapshot failed with ${response.status}`);
const snapshot = await response.json();
const queued = await pendingCommands(database);
@@ -497,6 +526,7 @@ async function synchronize(command) {
}
synchronizing = false;
source.close();
if (acknowledgementSource === source) acknowledgementSource = undefined;
clearTimeout(leaseTimer);
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
@@ -564,7 +594,8 @@ async function runLeaseLoop(command) {
async function start() {
if (!root) return;
const contextResponse = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" });
root.setAttribute("data-sync-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
const contextResponse = await fetchWithTimeout("/sync/context", { credentials: "same-origin", cache: "no-store" });
if (!contextResponse.ok) throw new Error(`account context failed with ${contextResponse.status}`);
const context = await contextResponse.json();
if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) {
@@ -633,6 +664,12 @@ async function start() {
window.addEventListener("pagehide", () => {
stopped = true;
clearTimeout(leaseTimer);
clearTimeout(retryTimer);
acknowledgementSource?.close();
acknowledgementSource = undefined;
for (const controller of activeRequests) {
controller.abort(new DOMException("sync cancelled because page is hidden", "AbortError"));
}
if (database) releaseUploaderLease(database).catch(() => {});
});