Compare commits

...
Author SHA1 Message Date
陈大猫 bb5c550ac7 perf(browser): coalesce download progress history writes
Keep live progress notifications immediate while coalescing replaceable byte-counter persistence. Flush starts, metadata, terminal states, deletions and shutdown synchronously.
2026-10-21 23:12:55 +08:00
4 changed files with 194 additions and 5 deletions

No files matched your search

+32 -3
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs");
const path = require("node:path");
const MAX_DOWNLOADS = 500;
const PROGRESS_PERSIST_INTERVAL_MS = 1000;
const STATUSES = new Set(["waiting", "progressing", "completed", "cancelled", "interrupted"]);
function normalizeDownload(raw) {
@@ -40,11 +41,29 @@ function createDownloadStore(app) {
} catch (error) {
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read downloads:", error?.message);
}
let dirty = false;
let progressTimer = null;
const persist = () => {
if (progressTimer !== null) clearTimeout(progressTimer);
progressTimer = null;
if (!dirty) return;
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(items), { mode: 0o600 });
fs.renameSync(temporary, file);
dirty = false;
};
const scheduleProgressPersist = () => {
if (progressTimer !== null) return;
progressTimer = setTimeout(() => {
progressTimer = null;
try { persist(); }
catch (error) {
// Keep dirty state for the next update or shutdown, without a retry loop.
console.warn("[HostBrowser] Could not save download progress:", error?.message);
}
}, PROGRESS_PERSIST_INTERVAL_MS);
progressTimer.unref?.();
};
const isMissing = (item) => item.status === "completed" && (!item.savePath || !fs.existsSync(item.savePath));
return {
@@ -54,26 +73,36 @@ function createDownloadStore(app) {
.map((item) => ({ ...item, missing: isMissing(item) }));
},
get(hostId, id) { return items.find((item) => item.hostId === hostId && item.id === id) || null; },
upsert(raw) {
flush: persist,
upsert(raw, { deferProgress = false } = {}) {
const next = normalizeDownload(raw);
const index = items.findIndex((item) => item.id === next.id);
if (index >= 0 && items[index].hostId !== next.hostId) throw new Error("Download belongs to another host");
const previous = items[index];
// Only byte progress is replaceable. New records, state transitions and
// recovery metadata remain durable before returning to the caller.
const replaceableProgress = deferProgress && previous?.status === "progressing"
&& next.status === "progressing"
&& ["filename", "url", "savePath", "total", "startedAt", "endedAt"].every((key) => previous[key] === next[key]);
if (index >= 0) items[index] = next;
else items.unshift(next);
items = items.sort((a, b) => b.startedAt - a.startedAt).slice(0, MAX_DOWNLOADS);
persist();
dirty = true;
if (replaceableProgress) scheduleProgressPersist();
else persist();
return next;
},
remove(hostId, id) {
const next = items.filter((item) => item.hostId !== hostId || item.id !== id || ["waiting", "progressing"].includes(item.status));
if (next.length === items.length) return false;
items = next;
dirty = true;
persist();
return true;
},
clearFinished(hostId) {
const next = items.filter((item) => item.hostId !== hostId || ["waiting", "progressing"].includes(item.status));
if (next.length !== items.length) { items = next; persist(); }
if (next.length !== items.length) { items = next; dirty = true; persist(); }
},
};
}
+109
View File
@@ -58,3 +58,112 @@ test("cleanup removes only its own empty reservation", (t) => {
reserved.cleanupEmpty();
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
});
function progressFixture(t) {
const vm = require("node:vm");
const directory = fs.mkdtempSync(getTempFilePath("host-browser-progress-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const file = path.join(directory, "host-browser-downloads.json");
const timers = new Map();
let nextTimer = 0;
let writes = 0;
let fail = false;
const warnings = [];
const fakeFs = { ...fs, writeFileSync(...args) {
if (fail) throw new Error("disk full");
writes++;
return fs.writeFileSync(...args);
} };
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(require.resolve("./downloadStore.cjs"), "utf8"), {
module, process, console: { warn: (...args) => warnings.push(args) },
require: (name) => name === "node:fs" ? fakeFs : require(name),
setTimeout(fn, delay) { assert.equal(delay, 1000); const token = { id: ++nextTimer, unref() {} }; timers.set(token, fn); return token; },
clearTimeout(token) { timers.delete(token); },
});
const store = module.exports.createDownloadStore({ getPath: () => directory });
const row = { id: "active", hostId: "a", filename: "file.zip", savePath: path.join(directory, "file.zip"), status: "progressing", startedAt: 1, total: 100, received: 0 };
return { store, row, timers, warnings, get writes() { return writes; }, fail(value) { fail = value; },
saved: () => JSON.parse(fs.readFileSync(file, "utf8")),
tick() { const callbacks = [...timers.values()]; timers.clear(); for (const fn of callbacks) fn(); },
};
}
test("progress is immediate in memory and coalesced across downloads, then terminal state is durable", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, id: "second" });
for (let received = 1; received <= 20; received++) {
for (const id of ["active", "second"]) f.store.upsert({ ...f.row, id, received }, { deferProgress: true });
}
assert.equal(f.writes, 2);
assert.equal(f.timers.size, 1);
assert.equal(f.store.list("a")[0].received, 20);
assert.equal(f.saved()[0].received, 0);
f.tick();
assert.equal(f.writes, 3);
assert.equal(f.saved()[0].received, 20);
f.store.upsert({ ...f.row, received: 50 }, { deferProgress: true });
f.store.upsert({ ...f.row, status: "completed", received: 100, endedAt: 2 });
assert.equal(f.timers.size, 0);
assert.equal(f.saved().find((item) => item.id === "active").status, "completed");
f.tick();
assert.equal(f.writes, 4);
});
test("new records, metadata and interrupted/cancelled transitions never defer", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row, { deferProgress: true });
assert.equal(f.writes, 1);
for (const update of [{ savePath: "changed" }, { total: 200 }, { status: "interrupted" }, { status: "cancelled" }]) {
const next = { ...f.store.get("a", "active"), ...update };
const before = f.writes;
f.store.upsert(next, { deferProgress: true });
assert.equal(f.writes, before + 1);
assert.equal(f.timers.size, 0);
}
assert.throws(() => f.store.upsert({ ...f.row, hostId: "b" }, { deferProgress: true }), /another host/);
assert.equal(f.store.get("a", "active").status, "cancelled");
});
test("flush persists pending progress and removal cannot be resurrected by a pending timer", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
f.store.flush();
assert.equal(f.timers.size, 0);
assert.equal(f.saved()[0].received, 10);
f.store.flush();
assert.equal(f.writes, 2);
f.store.upsert({ ...f.row, id: "finished", status: "completed" });
f.store.upsert({ ...f.row, received: 20 }, { deferProgress: true });
assert.equal(f.store.remove("b", "finished"), false);
assert.equal(f.store.remove("a", "active"), false);
assert.equal(f.store.remove("a", "finished"), true);
assert.equal(f.timers.size, 0);
f.tick();
assert.equal(f.saved().length, 1);
assert.equal(f.saved()[0].received, 20);
f.store.upsert({ ...f.row, id: "finished", status: "cancelled" });
f.store.upsert({ ...f.row, received: 30 }, { deferProgress: true });
f.store.clearFinished("a");
assert.equal(f.timers.size, 0);
assert.equal(f.saved().length, 1);
assert.equal(f.saved()[0].received, 30);
});
test("failed deferred writes retain dirty state, preserve disk snapshot and do not spin", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
f.fail(true);
assert.doesNotThrow(() => f.tick());
assert.equal(f.warnings.length, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.saved()[0].received, 0);
assert.throws(() => f.store.flush(), /disk full/);
f.fail(false);
f.store.flush();
assert.equal(f.saved()[0].received, 10);
assert.equal(f.timers.size, 0);
});
+4 -2
View File
@@ -635,13 +635,14 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
let lastProgressAt = 0;
item.on("updated", (_updateEvent, status) => {
const now = Date.now();
if (status === "progressing" && now - lastProgressAt < 200) return;
const paused = item.isPaused?.() === true;
if (status === "progressing" && !paused && now - lastProgressAt < 200) return;
lastProgressAt = now;
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
downloadStore.upsert({ ...previous, filename: savePath ? path.basename(savePath) : filename,
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() });
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() }, { deferProgress: !paused });
notifyDownloads(record);
});
item.once("done", (_doneEvent, status) => {
@@ -936,6 +937,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
unsubscribeTunnelStatus();
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
downloadStore.flush();
} };
}
@@ -68,3 +68,52 @@ test("late download notifications ignore closed browser chrome in a live termina
});
assert.doesNotThrow(() => notify(record));
});
test("manager shutdown flushes download history after closing windows", () => {
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
const start = source.indexOf("closeAll: () => {");
const end = source.indexOf("} };", start);
assert.ok(start > 0 && end > start);
const calls = [];
const closeAll = vm.runInNewContext(`(() => {${source.slice(start + "closeAll: () => {".length, end)}})`, {
unsubscribeTunnelStatus: () => calls.push("unsubscribe"),
windows: new Map([[1, { embedded: true }]]),
closeRecord: () => calls.push("close"),
downloadStore: { flush: () => calls.push("flush") },
});
closeAll();
assert.deepEqual(calls, ["unsubscribe", "close", "flush"]);
});
test("download handler defers only live progress, not pause or completion", () => {
const { EventEmitter } = require("node:events");
const path = require("node:path");
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
const start = source.indexOf("record.downloadHandler =");
const end = source.indexOf('pageSession.on("will-download"', start);
const calls = [];
let previous;
let now = 1000;
const record = { route: "connected", embedded: true, downloads: new Map(), tabs: new Map([[1, { view: { webContents: "owned" } }]]) };
vm.runInNewContext(source.slice(start, end), {
record, path, randomUUID: () => "id", hostId: "host", app: { getPath: () => "/downloads" },
reserveDownloadFile: () => ({ savePath: "/downloads/file", cleanupEmpty() {} }),
downloadStore: { get: () => previous, upsert(row, options) { previous = row; calls.push({ row, options }); } },
notifyDownloads() {}, Date: { now: () => now },
});
const item = Object.assign(new EventEmitter(), {
paused: false, isPaused() { return this.paused; }, getFilename: () => "file", getURL: () => "https://example.test/file",
getTotalBytes: () => 100, getReceivedBytes: () => 10, getSavePath: () => "/downloads/file", setSavePath() {},
});
record.downloadHandler({ preventDefault() { assert.fail("rejected owned download"); } }, item, "owned");
assert.equal(calls[0].options, undefined);
item.emit("updated", {}, "progressing");
assert.equal(calls[1].options.deferProgress, true);
now += 1;
item.paused = true;
item.emit("updated", {}, "progressing");
assert.equal(calls[2].options.deferProgress, false, "pause bypasses the byte-progress throttle");
item.emit("done", {}, "completed");
assert.equal(calls[3].options, undefined);
assert.equal(calls[3].row.status, "completed");
});