fix: harden diagnostics persistence and update downloads
This commit is contained in:
@@ -44,7 +44,7 @@ import { defaultSettings } from "../src/main/constants";
|
||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||
import { createRendererState } from "../src/main/renderer-state";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
||||
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
|
||||
import { getDebugServerRuntimeStatus, restartDebugServer, startDebugServer, stopDebugServer, writeDebugServerConfig } from "../src/main/debug-server";
|
||||
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
|
||||
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||
@@ -370,6 +370,60 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("debug-server", () => {
|
||||
it("keeps exactly one reachable server across concurrent restarts", async () => {
|
||||
const fixture = await createFixture();
|
||||
writeDebugServerConfig({ allowlist: ["203.0.113.5"] });
|
||||
|
||||
await Promise.all([restartDebugServer(), restartDebugServer()]);
|
||||
|
||||
expect(getDebugServerRuntimeStatus()).toEqual(expect.objectContaining({
|
||||
running: true,
|
||||
allowlistCount: 1
|
||||
}));
|
||||
const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token);
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reopen after stop races with queued restarts", async () => {
|
||||
const fixture = await createFixture();
|
||||
const restarts = Promise.all([restartDebugServer(), restartDebugServer()]);
|
||||
|
||||
stopDebugServer();
|
||||
await restarts;
|
||||
|
||||
expect(getDebugServerRuntimeStatus().running).toBe(false);
|
||||
await expect(fetch(`${fixture.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("settles the lifecycle queue when stopped during socket startup", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-start-stop-"));
|
||||
tempDirs.push(baseDir);
|
||||
const port = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "debug-secret", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||
|
||||
startDebugServer({} as DownloadManager, baseDir);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
stopDebugServer();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
restartDebugServer().then(() => "settled"),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 500))
|
||||
]);
|
||||
|
||||
expect(outcome).toBe("settled");
|
||||
expect(getDebugServerRuntimeStatus().running).toBe(false);
|
||||
|
||||
startDebugServer({} as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${port}/health`);
|
||||
expect(getDebugServerRuntimeStatus().running).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
|
||||
|
||||
@@ -185,17 +185,17 @@ describe("session restart loss", () => {
|
||||
expect(Object.keys(loaded.items)).toEqual([]);
|
||||
});
|
||||
|
||||
it("recovers from the backup when the primary exists but is empty", () => {
|
||||
it("keeps a valid empty primary authoritative over an older populated backup", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
|
||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
||||
});
|
||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(Object.keys(loaded.packages)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not let an in-flight/queued async settings save clobber a newer synchronous saveSettings", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-race-"));
|
||||
|
||||
+605
-10
@@ -1907,7 +1907,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.packageOrder).toEqual(empty.packageOrder);
|
||||
});
|
||||
|
||||
it("loads backup session when primary session is corrupted", () => {
|
||||
it("loads backup session when primary session is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -1958,8 +1958,68 @@ describe("settings storage", () => {
|
||||
expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar");
|
||||
|
||||
const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> };
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a valid intentionally empty primary session authoritative over its populated backup", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-empty-primary-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const populated = normalizeLoadedSession({
|
||||
...emptySession(),
|
||||
packageOrder: ["pkg-old"],
|
||||
packages: {
|
||||
"pkg-old": {
|
||||
id: "pkg-old",
|
||||
name: "Old Package",
|
||||
outputDir: path.join(dir, "out"),
|
||||
extractDir: path.join(dir, "extract"),
|
||||
status: "queued",
|
||||
itemIds: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
saveSession(paths, populated);
|
||||
saveSession(paths, emptySession());
|
||||
const loaded = loadSessionWithStatus(paths);
|
||||
|
||||
expect(loaded.status).toBe("ok");
|
||||
expect(loaded.session.packageOrder).toEqual([]);
|
||||
expect(loaded.session.packages).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty object", {}],
|
||||
["null", null],
|
||||
["array", []]
|
||||
])("recovers the backup when the primary contains a JSON-valid invalid %s envelope", (_label, invalidPrimary) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-invalid-session-envelope-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const backup = normalizeLoadedSession({
|
||||
...emptySession(),
|
||||
packageOrder: ["pkg-backup"],
|
||||
packages: {
|
||||
"pkg-backup": {
|
||||
id: "pkg-backup",
|
||||
name: "Backup Package",
|
||||
outputDir: path.join(dir, "out"),
|
||||
extractDir: path.join(dir, "extract"),
|
||||
status: "queued",
|
||||
itemIds: []
|
||||
}
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(backup), "utf8");
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify(invalidPrimary), "utf8");
|
||||
|
||||
const loaded = loadSessionWithStatus(paths);
|
||||
|
||||
expect(loaded.status).toBe("recovered-backup");
|
||||
expect(loaded.session.packageOrder).toEqual(["pkg-backup"]);
|
||||
expect(Object.keys(loaded.session.packages)).toEqual(["pkg-backup"]);
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
@@ -1977,7 +2037,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.cleanupMode).toBe(defaults.cleanupMode);
|
||||
});
|
||||
|
||||
it("loads backup config when primary config is corrupted", () => {
|
||||
it("loads backup config when primary config is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -1992,8 +2052,70 @@ describe("settings storage", () => {
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.outputDir).toBe(backupSettings.outputDir);
|
||||
expect(loaded.packageName).toBe("from-backup");
|
||||
});
|
||||
expect(loaded.packageName).toBe("from-backup");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty object", {}],
|
||||
["null", null],
|
||||
["array", []],
|
||||
["unrecognized object", { unrelated: true }]
|
||||
])("loads and repairs the backup when the primary config contains a JSON-valid invalid %s envelope", (_label, invalidPrimary) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-invalid-settings-envelope-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const backupSettings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(dir, "backup-output"),
|
||||
packageName: "from-valid-backup"
|
||||
};
|
||||
fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify(backupSettings), "utf8");
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify(invalidPrimary), "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
|
||||
expect(loaded.outputDir).toBe(backupSettings.outputDir);
|
||||
expect(loaded.packageName).toBe("from-valid-backup");
|
||||
expect(loadSettings(paths).packageName).toBe("from-valid-backup");
|
||||
});
|
||||
|
||||
it("keeps a sparse legacy primary config authoritative over its backup", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-sparse-settings-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const legacyOutputDir = path.join(dir, "legacy-output");
|
||||
fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify({
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(dir, "backup-output"),
|
||||
packageName: "from-backup"
|
||||
}), "utf8");
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify({ outputDir: legacyOutputDir }), "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
|
||||
expect(loaded.outputDir).toBe(legacyOutputDir);
|
||||
expect(loaded.packageName).toBe("");
|
||||
});
|
||||
|
||||
it("loads and repairs backup config when the primary config is missing", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-missing-config-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(dir, "kept-output"),
|
||||
packageName: "from-existing-backup"
|
||||
};
|
||||
saveSettings(paths, settings);
|
||||
fs.rmSync(paths.configFile);
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
|
||||
expect(loaded.outputDir).toBe(settings.outputDir);
|
||||
expect(loaded.packageName).toBe("from-existing-backup");
|
||||
expect(loadSettings(paths).packageName).toBe("from-existing-backup");
|
||||
expect(fs.existsSync(paths.configFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("sanitizes malformed persisted session structures", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
@@ -2035,7 +2157,7 @@ describe("settings storage", () => {
|
||||
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
|
||||
});
|
||||
|
||||
it("drops unsafe session ids and target paths outside the package output directory", () => {
|
||||
it("drops unsafe session ids and target paths outside the package output directory", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
@@ -2101,9 +2223,105 @@ describe("settings storage", () => {
|
||||
expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]);
|
||||
expect(loaded.packageOrder).toEqual(["pkg-safe"]);
|
||||
expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath));
|
||||
expect(loaded.items["item-outside"]?.targetPath).toBe("");
|
||||
});
|
||||
|
||||
expect(loaded.items["item-outside"]?.targetPath).toBe("");
|
||||
});
|
||||
|
||||
it("preserves a metadata rename journal inside the package output directory across save and load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const outputDir = path.join(dir, "downloads", "journal");
|
||||
const currentPath = path.join(outputDir, "opaque.bin");
|
||||
const renameTargetPath = path.join(outputDir, "resolved-name.rar");
|
||||
const session = emptySession();
|
||||
session.packageOrder = ["pkg-journal"];
|
||||
session.packages["pkg-journal"] = {
|
||||
id: "pkg-journal",
|
||||
name: "Journal Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract", "journal"),
|
||||
status: "completed",
|
||||
itemIds: ["item-journal"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
session.items["item-journal"] = {
|
||||
id: "item-journal",
|
||||
packageId: "pkg-journal",
|
||||
url: "https://ddownload.com/journal123",
|
||||
provider: null,
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 100,
|
||||
fileName: "resolved-name.rar",
|
||||
targetPath: currentPath,
|
||||
metadataRenameTargetPath: renameTargetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(loaded.items["item-journal"]?.metadataRenameTargetPath).toBe(path.resolve(renameTargetPath));
|
||||
});
|
||||
|
||||
it("drops a metadata rename journal outside the package output directory", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const outputDir = path.join(dir, "downloads", "journal");
|
||||
const session = emptySession();
|
||||
session.packageOrder = ["pkg-journal"];
|
||||
session.packages["pkg-journal"] = {
|
||||
id: "pkg-journal",
|
||||
name: "Journal Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract", "journal"),
|
||||
status: "completed",
|
||||
itemIds: ["item-journal"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
session.items["item-journal"] = {
|
||||
id: "item-journal",
|
||||
packageId: "pkg-journal",
|
||||
url: "https://ddownload.com/journal123",
|
||||
provider: null,
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 1024,
|
||||
progressPercent: 100,
|
||||
fileName: "resolved-name.rar",
|
||||
targetPath: path.join(outputDir, "opaque.bin"),
|
||||
metadataRenameTargetPath: path.join(dir, "outside", "resolved-name.rar"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(loaded.items["item-journal"]?.metadataRenameTargetPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures async session save payload before later mutations", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -2120,6 +2338,158 @@ describe("settings storage", () => {
|
||||
expect(persisted.summaryText).toBe("before-mutation");
|
||||
});
|
||||
|
||||
it("keeps a queued async settings save pending until its payload is persisted", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-settings-queue-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
saveSettings(paths, defaultSettings());
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let releaseFirstWrite = () => {};
|
||||
let markFirstWriteStarted = () => {};
|
||||
const firstWriteStarted = new Promise<void>((resolve) => { markFirstWriteStarted = resolve; });
|
||||
const firstWriteRelease = new Promise<void>((resolve) => { releaseFirstWrite = resolve; });
|
||||
let settingsTempWrites = 0;
|
||||
vi.spyOn(fs.promises, "writeFile").mockImplementation(async (filePath, data, options) => {
|
||||
await writeFile(filePath, data, options);
|
||||
if (String(filePath) === `${paths.configFile}.settings.tmp` && settingsTempWrites++ === 0) {
|
||||
markFirstWriteStarted();
|
||||
await firstWriteRelease;
|
||||
}
|
||||
});
|
||||
|
||||
const first = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "first" });
|
||||
await firstWriteStarted;
|
||||
let secondSettled = false;
|
||||
const second = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "second" })
|
||||
.then(() => { secondSettled = true; });
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const settledWhileBlocked = secondSettled;
|
||||
|
||||
releaseFirstWrite();
|
||||
await Promise.all([first, second]);
|
||||
await vi.waitFor(() => expect(loadSettings(paths).packageName).toBe("second"));
|
||||
|
||||
expect(settledWhileBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a queued async session save pending until its payload is persisted", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-session-queue-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const openFile = fs.promises.open.bind(fs.promises);
|
||||
let releaseFirstWrite = () => {};
|
||||
let markFirstWriteStarted = () => {};
|
||||
const firstWriteStarted = new Promise<void>((resolve) => { markFirstWriteStarted = resolve; });
|
||||
const firstWriteRelease = new Promise<void>((resolve) => { releaseFirstWrite = resolve; });
|
||||
let sessionTempWrites = 0;
|
||||
vi.spyOn(fs.promises, "open").mockImplementation(async (filePath, flags, mode) => {
|
||||
const handle = await openFile(filePath, flags, mode);
|
||||
if (String(filePath) === `${paths.sessionFile}.async.tmp`) {
|
||||
const writeFile = handle.writeFile.bind(handle);
|
||||
handle.writeFile = async (...args: Parameters<typeof handle.writeFile>) => {
|
||||
const result = await writeFile(...args);
|
||||
if (sessionTempWrites++ === 0) {
|
||||
markFirstWriteStarted();
|
||||
await firstWriteRelease;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
|
||||
const first = saveSessionAsync(paths, { ...emptySession(), summaryText: "first" });
|
||||
await firstWriteStarted;
|
||||
let secondSettled = false;
|
||||
const second = saveSessionAsync(paths, { ...emptySession(), summaryText: "second" })
|
||||
.then(() => { secondSettled = true; });
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const settledWhileBlocked = secondSettled;
|
||||
|
||||
releaseFirstWrite();
|
||||
await Promise.all([first, second]);
|
||||
await vi.waitFor(() => expect(loadSession(paths).summaryText).toBe("second"));
|
||||
|
||||
expect(settledWhileBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("drains a settings save queued in the completion tail", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-settings-tail-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const renameFile = fs.renameSync.bind(fs);
|
||||
let secondSave: Promise<void> | null = null;
|
||||
let markSecondSaveStarted = () => {};
|
||||
const secondSaveStarted = new Promise<void>((resolve) => { markSecondSaveStarted = resolve; });
|
||||
let scheduled = false;
|
||||
vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
|
||||
const result = renameFile(source, destination);
|
||||
if (!scheduled && String(destination) === paths.configFile) {
|
||||
scheduled = true;
|
||||
queueMicrotask(() => queueMicrotask(() => {
|
||||
secondSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "second" });
|
||||
markSecondSaveStarted();
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const firstSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "first" });
|
||||
await secondSaveStarted;
|
||||
await firstSave;
|
||||
expect(secondSave).not.toBeNull();
|
||||
await secondSave;
|
||||
|
||||
expect(loadSettings(paths).packageName).toBe("second");
|
||||
});
|
||||
|
||||
it("drains a session save queued in the completion tail", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-session-tail-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const renameFile = fs.renameSync.bind(fs);
|
||||
let secondSave: Promise<void> | null = null;
|
||||
let markSecondSaveStarted = () => {};
|
||||
const secondSaveStarted = new Promise<void>((resolve) => { markSecondSaveStarted = resolve; });
|
||||
let scheduled = false;
|
||||
vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
|
||||
const result = renameFile(source, destination);
|
||||
if (!scheduled && String(destination) === paths.sessionFile) {
|
||||
scheduled = true;
|
||||
queueMicrotask(() => queueMicrotask(() => {
|
||||
secondSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "second" });
|
||||
markSecondSaveStarted();
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const firstSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "first" });
|
||||
await secondSaveStarted;
|
||||
await firstSave;
|
||||
expect(secondSave).not.toBeNull();
|
||||
await secondSave;
|
||||
|
||||
expect(loadSession(paths).summaryText).toBe("second");
|
||||
});
|
||||
|
||||
it("rejects async settings and session saves when persistence fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-async-errors-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const settingsFailure = vi.spyOn(fs.promises, "writeFile")
|
||||
.mockRejectedValueOnce(new Error("settings write failed"));
|
||||
|
||||
await expect(saveSettingsAsync(paths, defaultSettings())).rejects.toThrow("settings write failed");
|
||||
settingsFailure.mockRestore();
|
||||
|
||||
const sessionFailure = vi.spyOn(fs.promises, "open")
|
||||
.mockRejectedValueOnce(new Error("session write failed"));
|
||||
|
||||
await expect(saveSessionAsync(paths, emptySession())).rejects.toThrow("session write failed");
|
||||
sessionFailure.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps newer synchronous settings and session saves when older async commits resume", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-sync-wins-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -2260,6 +2630,8 @@ describe("settings storage", () => {
|
||||
releaseSessionWrite();
|
||||
const barrier = await barrierPromise;
|
||||
await Promise.all([oldSettingsSave, oldSessionSave]);
|
||||
expect(loadSettings(paths).packageName).toBe("old-settings");
|
||||
expect(loadSession(paths).summaryText).toBe("old-session");
|
||||
|
||||
const blockedSettingsSave = saveSettingsAsync(paths, oldSettings);
|
||||
const blockedSessionSave = saveSessionAsync(paths, oldSession);
|
||||
@@ -2295,6 +2667,229 @@ describe("settings storage", () => {
|
||||
await nextBarrier.release({ replayBlocked: false });
|
||||
});
|
||||
|
||||
it("cleans up a barrier acquisition after an active write fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-acquire-failure-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
let markWriteStarted = () => {};
|
||||
const writeStarted = new Promise<void>((resolve) => { markWriteStarted = resolve; });
|
||||
let rejectWrite = (_error: Error) => {};
|
||||
const writeRelease = new Promise<void>((_resolve, reject) => { rejectWrite = reject; });
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
vi.spyOn(fs.promises, "writeFile").mockImplementation((filePath, data, options) => {
|
||||
if (String(filePath) === `${paths.configFile}.settings.tmp`) {
|
||||
markWriteStarted();
|
||||
return writeRelease;
|
||||
}
|
||||
return writeFile(filePath, data, options);
|
||||
});
|
||||
|
||||
const activeSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "failing" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
await writeStarted;
|
||||
const barrierAttempt = acquirePersistenceBarrier();
|
||||
const blockedSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "blocked" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
rejectWrite(new Error("active write failed"));
|
||||
|
||||
await expect(barrierAttempt).rejects.toThrow("active write failed");
|
||||
expect(await activeSave).toBe("rejected:active write failed");
|
||||
expect(await Promise.race([
|
||||
blockedSave,
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 100))
|
||||
])).toBe("rejected:active write failed");
|
||||
vi.restoreAllMocks();
|
||||
|
||||
await saveSettingsAsync(paths, { ...defaultSettings(), packageName: "after-failure" });
|
||||
expect(loadSettings(paths).packageName).toBe("after-failure");
|
||||
const nextBarrier = await acquirePersistenceBarrier();
|
||||
await nextBarrier.release({ replayBlocked: false });
|
||||
});
|
||||
|
||||
it("requeues a pending settings save when barrier acquisition fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-settings-requeue-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let markWriteStarted = () => {};
|
||||
const writeStarted = new Promise<void>((resolve) => { markWriteStarted = resolve; });
|
||||
let rejectWrite = (_error: Error) => {};
|
||||
const writeRelease = new Promise<void>((_resolve, reject) => { rejectWrite = reject; });
|
||||
let settingsWrites = 0;
|
||||
vi.spyOn(fs.promises, "writeFile").mockImplementation((filePath, data, options) => {
|
||||
if (String(filePath) === `${paths.configFile}.settings.tmp` && settingsWrites++ === 0) {
|
||||
markWriteStarted();
|
||||
return writeRelease;
|
||||
}
|
||||
return writeFile(filePath, data, options);
|
||||
});
|
||||
|
||||
const activeSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "active" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
await writeStarted;
|
||||
let queuedSettled = false;
|
||||
const queuedSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "queued-latest" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`)
|
||||
.finally(() => { queuedSettled = true; });
|
||||
const barrierAttempt = acquirePersistenceBarrier();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const settledBeforeFailure = queuedSettled;
|
||||
rejectWrite(new Error("active settings write failed"));
|
||||
|
||||
await expect(barrierAttempt).rejects.toThrow("active settings write failed");
|
||||
expect(await activeSave).toBe("rejected:active settings write failed");
|
||||
expect(await queuedSave).toBe("resolved");
|
||||
expect(settledBeforeFailure).toBe(false);
|
||||
expect(loadSettings(paths).packageName).toBe("queued-latest");
|
||||
});
|
||||
|
||||
it("requeues a pending session save when barrier acquisition fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-session-requeue-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const openFile = fs.promises.open.bind(fs.promises);
|
||||
let markOpenStarted = () => {};
|
||||
const openStarted = new Promise<void>((resolve) => { markOpenStarted = resolve; });
|
||||
let rejectOpen = (_error: Error) => {};
|
||||
const openRelease = new Promise<never>((_resolve, reject) => { rejectOpen = reject; });
|
||||
let sessionOpens = 0;
|
||||
vi.spyOn(fs.promises, "open").mockImplementation((filePath, flags, mode) => {
|
||||
if (String(filePath) === `${paths.sessionFile}.async.tmp` && sessionOpens++ === 0) {
|
||||
markOpenStarted();
|
||||
return openRelease;
|
||||
}
|
||||
return openFile(filePath, flags, mode);
|
||||
});
|
||||
|
||||
const activeSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "active" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
await openStarted;
|
||||
let queuedSettled = false;
|
||||
const queuedSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "queued-latest" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`)
|
||||
.finally(() => { queuedSettled = true; });
|
||||
const barrierAttempt = acquirePersistenceBarrier();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const settledBeforeFailure = queuedSettled;
|
||||
rejectOpen(new Error("active session open failed"));
|
||||
|
||||
await expect(barrierAttempt).rejects.toThrow("active session open failed");
|
||||
expect(await activeSave).toBe("rejected:active session open failed");
|
||||
expect(await queuedSave).toBe("resolved");
|
||||
expect(settledBeforeFailure).toBe(false);
|
||||
expect(loadSession(paths).summaryText).toBe("queued-latest");
|
||||
});
|
||||
|
||||
it("rejects pre-barrier queued saves when a successful import barrier discards them", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-explicit-discard-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
saveSettings(paths, defaultSettings());
|
||||
saveSession(paths, emptySession());
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
const openFile = fs.promises.open.bind(fs.promises);
|
||||
let releaseSettingsWrite = () => {};
|
||||
let releaseSessionWrite = () => {};
|
||||
let markSettingsWriteStarted = () => {};
|
||||
let markSessionWriteStarted = () => {};
|
||||
const settingsWriteStarted = new Promise<void>((resolve) => { markSettingsWriteStarted = resolve; });
|
||||
const sessionWriteStarted = new Promise<void>((resolve) => { markSessionWriteStarted = resolve; });
|
||||
const settingsWriteRelease = new Promise<void>((resolve) => { releaseSettingsWrite = resolve; });
|
||||
const sessionWriteRelease = new Promise<void>((resolve) => { releaseSessionWrite = resolve; });
|
||||
vi.spyOn(fs.promises, "writeFile").mockImplementation(async (filePath, data, options) => {
|
||||
await writeFile(filePath, data, options);
|
||||
if (String(filePath) === `${paths.configFile}.settings.tmp`) {
|
||||
markSettingsWriteStarted();
|
||||
await settingsWriteRelease;
|
||||
}
|
||||
});
|
||||
vi.spyOn(fs.promises, "open").mockImplementation(async (filePath, flags, mode) => {
|
||||
const handle = await openFile(filePath, flags, mode);
|
||||
if (String(filePath) === `${paths.sessionFile}.async.tmp`) {
|
||||
const originalWrite = handle.writeFile.bind(handle);
|
||||
handle.writeFile = async (...args: Parameters<typeof handle.writeFile>) => {
|
||||
const result = await originalWrite(...args);
|
||||
markSessionWriteStarted();
|
||||
await sessionWriteRelease;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
|
||||
const activeSettings = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "active" });
|
||||
const activeSession = saveSessionAsync(paths, { ...emptySession(), summaryText: "active" });
|
||||
await Promise.all([settingsWriteStarted, sessionWriteStarted]);
|
||||
const queuedSettings = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "discarded" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
const queuedSession = saveSessionAsync(paths, { ...emptySession(), summaryText: "discarded" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
const barrierAttempt = acquirePersistenceBarrier();
|
||||
releaseSettingsWrite();
|
||||
releaseSessionWrite();
|
||||
const barrier = await barrierAttempt;
|
||||
await Promise.all([activeSettings, activeSession]);
|
||||
|
||||
expect(await queuedSettings).toMatch(/^rejected:.*barrier/i);
|
||||
expect(await queuedSession).toMatch(/^rejected:.*barrier/i);
|
||||
saveSettings(paths, { ...defaultSettings(), packageName: "imported" });
|
||||
saveSession(paths, { ...emptySession(), summaryText: "imported" });
|
||||
await barrier.release({ replayBlocked: false });
|
||||
expect(loadSettings(paths).packageName).toBe("imported");
|
||||
expect(loadSession(paths).summaryText).toBe("imported");
|
||||
});
|
||||
|
||||
it("waits for every started replay write before releasing a failed barrier", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-replay-failure-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const openFile = fs.promises.open.bind(fs.promises);
|
||||
let markSessionWriteStarted = () => {};
|
||||
const sessionWriteStarted = new Promise<void>((resolve) => { markSessionWriteStarted = resolve; });
|
||||
let releaseSessionWrite = () => {};
|
||||
const sessionWriteRelease = new Promise<void>((resolve) => { releaseSessionWrite = resolve; });
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
vi.spyOn(fs.promises, "writeFile").mockImplementation((filePath, data, options) => {
|
||||
if (String(filePath) === `${paths.configFile}.settings.tmp`) {
|
||||
return Promise.reject(new Error("replay settings failed"));
|
||||
}
|
||||
return writeFile(filePath, data, options);
|
||||
});
|
||||
vi.spyOn(fs.promises, "open").mockImplementation(async (filePath, flags, mode) => {
|
||||
const handle = await openFile(filePath, flags, mode);
|
||||
if (String(filePath) === `${paths.sessionFile}.async.tmp`) {
|
||||
const writeFile = handle.writeFile.bind(handle);
|
||||
handle.writeFile = async (...args: Parameters<typeof handle.writeFile>) => {
|
||||
const result = await writeFile(...args);
|
||||
markSessionWriteStarted();
|
||||
await sessionWriteRelease;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
|
||||
const barrier = await acquirePersistenceBarrier();
|
||||
const settingsSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "replayed-settings" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
const sessionSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "replayed-session" })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`);
|
||||
let releaseSettled = false;
|
||||
const releaseResult = barrier.release({ replayBlocked: true })
|
||||
.then(() => "resolved", (error: unknown) => `rejected:${String((error as Error).message || error)}`)
|
||||
.finally(() => { releaseSettled = true; });
|
||||
await sessionWriteStarted;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(releaseSettled).toBe(false);
|
||||
releaseSessionWrite();
|
||||
expect(await releaseResult).toBe("rejected:replay settings failed");
|
||||
expect(await settingsSave).toBe("rejected:replay settings failed");
|
||||
expect(await sessionSave).toBe("resolved");
|
||||
const nextBarrier = await acquirePersistenceBarrier();
|
||||
await nextBarrier.release({ replayBlocked: false });
|
||||
});
|
||||
|
||||
it("creates session backup before sync and async session overwrites", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
+442
-24
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => {
|
||||
const unref = vi.fn();
|
||||
@@ -27,7 +28,7 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock
|
||||
}));
|
||||
|
||||
import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { abortActiveUpdateDownload, buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { APP_VERSION } from "../src/main/constants";
|
||||
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
||||
|
||||
@@ -37,12 +38,19 @@ function sha256Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function sha512Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha512").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
function sha512Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha512").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function createExecutablePayload() {
|
||||
const payload = Buffer.alloc(128 * 1024);
|
||||
payload.write("MZ");
|
||||
return payload;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
abortActiveUpdateDownload();
|
||||
globalThis.fetch = originalFetch;
|
||||
spawnMock.mockClear();
|
||||
unrefMock.mockClear();
|
||||
onceMock.mockClear();
|
||||
@@ -50,6 +58,416 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("settles a backpressured update write when the target stream errors", async () => {
|
||||
class FailingWriteStream extends EventEmitter {
|
||||
private markWriteStarted!: () => void;
|
||||
readonly writeStarted = new Promise<void>((resolve) => {
|
||||
this.markWriteStarted = resolve;
|
||||
});
|
||||
|
||||
write(): boolean {
|
||||
this.markWriteStarted();
|
||||
return false;
|
||||
}
|
||||
|
||||
end(callback?: () => void): this {
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new FailingWriteStream();
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(Buffer.alloc(160 * 1024, 1), {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
const pending = installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
await stream.writeStarted;
|
||||
const writeError = Object.assign(new Error("disk full"), { code: "ENOSPC" });
|
||||
stream.emit("error", writeError);
|
||||
const timeout = Symbol("timeout");
|
||||
const outcome = await Promise.race([
|
||||
pending,
|
||||
new Promise<typeof timeout>((resolve) => setTimeout(() => resolve(timeout), 100))
|
||||
]);
|
||||
|
||||
if (outcome === timeout) {
|
||||
stream.emit("drain");
|
||||
await pending;
|
||||
}
|
||||
|
||||
expect(outcome).not.toBe(timeout);
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect((outcome as { message: string }).message).toMatch(/disk full|ENOSPC/i);
|
||||
|
||||
const next = await installLatestUpdate("owner/repo", {
|
||||
updateAvailable: false,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: APP_VERSION,
|
||||
latestTag: `v${APP_VERSION}`,
|
||||
releaseUrl: "https://example.invalid/release"
|
||||
});
|
||||
expect(next.message).not.toBe("Update-Download läuft bereits");
|
||||
});
|
||||
|
||||
it("settles shutdown abort while an update write waits for drain", async () => {
|
||||
class BackpressuredWriteStream extends EventEmitter {
|
||||
private markWriteStarted!: () => void;
|
||||
private wasDestroyed = false;
|
||||
readonly writeStarted = new Promise<void>((resolve) => {
|
||||
this.markWriteStarted = resolve;
|
||||
});
|
||||
|
||||
write(): boolean {
|
||||
this.markWriteStarted();
|
||||
return false;
|
||||
}
|
||||
|
||||
end(callback?: () => void): this {
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
this.wasDestroyed = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
emitLateDestroyError(error: Error): boolean {
|
||||
if (!this.wasDestroyed) {
|
||||
throw new Error("stream was not destroyed");
|
||||
}
|
||||
const handled = this.listenerCount("error") > 0;
|
||||
if (handled) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
this.emit("close");
|
||||
return handled;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new BackpressuredWriteStream();
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(Buffer.alloc(160 * 1024, 1), {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
const pending = installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
await stream.writeStarted;
|
||||
abortActiveUpdateDownload();
|
||||
const timeout = Symbol("timeout");
|
||||
const outcome = await Promise.race([
|
||||
pending,
|
||||
new Promise<typeof timeout>((resolve) => setTimeout(() => resolve(timeout), 100))
|
||||
]);
|
||||
|
||||
if (outcome === timeout) {
|
||||
stream.emit("drain");
|
||||
await pending;
|
||||
}
|
||||
|
||||
expect(outcome).not.toBe(timeout);
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect((outcome as { message: string }).message).toMatch(/aborted:update_shutdown/i);
|
||||
expect(stream.emitLateDestroyError(new Error("late destroy failure"))).toBe(true);
|
||||
expect(stream.listenerCount("error")).toBe(0);
|
||||
|
||||
const next = await installLatestUpdate("owner/repo", {
|
||||
updateAvailable: false,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: APP_VERSION,
|
||||
latestTag: `v${APP_VERSION}`,
|
||||
releaseUrl: "https://example.invalid/release"
|
||||
});
|
||||
expect(next.message).not.toBe("Update-Download läuft bereits");
|
||||
});
|
||||
|
||||
it("does not wait for a stalled body cancellation during shutdown", async () => {
|
||||
class BackpressuredWriteStream extends EventEmitter {
|
||||
private markWriteStarted!: () => void;
|
||||
private markDestroyed!: () => void;
|
||||
readonly writeStarted = new Promise<void>((resolve) => {
|
||||
this.markWriteStarted = resolve;
|
||||
});
|
||||
readonly destroyed = new Promise<void>((resolve) => {
|
||||
this.markDestroyed = resolve;
|
||||
});
|
||||
|
||||
write(): boolean {
|
||||
this.markWriteStarted();
|
||||
return false;
|
||||
}
|
||||
|
||||
end(callback?: () => void): this {
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
this.markDestroyed();
|
||||
this.emit("close");
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new BackpressuredWriteStream();
|
||||
const cancelState: { release?: () => void } = {};
|
||||
let markCancellationRequested!: () => void;
|
||||
const cancellationRequested = new Promise<void>((resolve) => {
|
||||
markCancellationRequested = resolve;
|
||||
});
|
||||
const responseBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(160 * 1024));
|
||||
},
|
||||
cancel() {
|
||||
markCancellationRequested();
|
||||
return new Promise<void>((resolve) => {
|
||||
cancelState.release = resolve;
|
||||
});
|
||||
}
|
||||
});
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(responseBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
const pending = installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
await stream.writeStarted;
|
||||
abortActiveUpdateDownload();
|
||||
const timeout = Symbol("timeout");
|
||||
const outcome = await Promise.race([
|
||||
pending,
|
||||
new Promise<typeof timeout>((resolve) => setTimeout(() => resolve(timeout), 100))
|
||||
]);
|
||||
const cancellationOutcome = await Promise.race([
|
||||
cancellationRequested.then(() => true),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), 100))
|
||||
]);
|
||||
|
||||
if (outcome === timeout) {
|
||||
cancelState.release?.();
|
||||
await pending;
|
||||
}
|
||||
cancelState.release?.();
|
||||
|
||||
expect(outcome).not.toBe(timeout);
|
||||
expect(cancellationOutcome).toBe(true);
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect((outcome as { message: string }).message).toMatch(/aborted:update_shutdown/i);
|
||||
await expect(stream.destroyed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates the original write error from the end callback", async () => {
|
||||
const finalWriteError = Object.assign(new Error("final disk failure"), { code: "EIO" });
|
||||
|
||||
class FinalizingWriteStream extends EventEmitter {
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
end(callback?: (error?: Error) => void): this {
|
||||
callback?.(finalWriteError);
|
||||
this.emit("error", finalWriteError);
|
||||
this.emit("close");
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
this.emit("close");
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new FinalizingWriteStream();
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(Buffer.alloc(160 * 1024, 1), {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
const outcome = await installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect(outcome.message).toMatch(/final disk failure|EIO/i);
|
||||
});
|
||||
|
||||
it("waits for close and propagates a late close error after a successful end callback", async () => {
|
||||
const lateCloseError = Object.assign(new Error("late close failure EIO"), { code: "EIO" });
|
||||
|
||||
class LateCloseFailureStream extends EventEmitter {
|
||||
private closed = false;
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
end(callback?: (error?: Error) => void): this {
|
||||
callback?.();
|
||||
this.emit("finish");
|
||||
queueMicrotask(() => {
|
||||
this.emit("error", lateCloseError);
|
||||
this.closed = true;
|
||||
this.emit("close");
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
if (!this.closed) {
|
||||
this.closed = true;
|
||||
this.emit("close");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new LateCloseFailureStream();
|
||||
const renameSpy = vi.spyOn(fs.promises, "rename").mockRejectedValue(new Error("rename must not run"));
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(Buffer.alloc(160 * 1024, 1), {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
const outcome = await installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect(outcome.message).toMatch(/late close failure|EIO/i);
|
||||
expect(renameSpy).not.toHaveBeenCalled();
|
||||
expect(stream.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("settles an idle timeout while an update write waits for drain", async () => {
|
||||
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
|
||||
|
||||
class BackpressuredWriteStream extends EventEmitter {
|
||||
private markWriteStarted!: () => void;
|
||||
private markDestroyed!: () => void;
|
||||
readonly writeStarted = new Promise<void>((resolve) => {
|
||||
this.markWriteStarted = resolve;
|
||||
});
|
||||
readonly destroyed = new Promise<void>((resolve) => {
|
||||
this.markDestroyed = resolve;
|
||||
});
|
||||
|
||||
write(): boolean {
|
||||
this.markWriteStarted();
|
||||
return false;
|
||||
}
|
||||
|
||||
end(callback?: () => void): this {
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
this.markDestroyed();
|
||||
this.emit("close");
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new BackpressuredWriteStream();
|
||||
const externalErrorListener = (): void => undefined;
|
||||
stream.on("error", externalErrorListener);
|
||||
vi.spyOn(fs, "createWriteStream").mockReturnValue(stream as unknown as ReturnType<typeof fs.createWriteStream>);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(Buffer.alloc(160 * 1024, 1), {
|
||||
status: 200,
|
||||
headers: { "Content-Length": String(160 * 1024) }
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const pending = installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://example.invalid/release",
|
||||
setupAssetUrl: "https://example.invalid/",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: `sha256:${"a".repeat(64)}`
|
||||
});
|
||||
|
||||
await stream.writeStarted;
|
||||
const timeout = Symbol("timeout");
|
||||
const idleOutcome = await Promise.race([
|
||||
stream.destroyed.then(() => "destroyed" as const),
|
||||
new Promise<typeof timeout>((resolve) => setTimeout(() => resolve(timeout), 1600))
|
||||
]);
|
||||
abortActiveUpdateDownload();
|
||||
const outcome = await pending;
|
||||
|
||||
expect(idleOutcome).toBe("destroyed");
|
||||
expect(outcome).toEqual(expect.objectContaining({ started: false }));
|
||||
expect(stream.listenerCount("drain")).toBe(0);
|
||||
expect(stream.listeners("error")).toEqual([externalErrorListener]);
|
||||
} finally {
|
||||
if (previousTimeout === undefined) {
|
||||
delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("always refreshes release metadata before installing instead of using the previous check result", () => {
|
||||
const controller = fs.readFileSync(new URL("../src/main/app-controller.ts", import.meta.url), "utf8");
|
||||
const install = controller.slice(controller.indexOf("public async installUpdate"), controller.indexOf("public addLinks"));
|
||||
@@ -172,8 +590,8 @@ describe("update", () => {
|
||||
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
|
||||
});
|
||||
|
||||
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
const executableDigest = sha256Hex(executablePayload);
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
@@ -209,8 +627,8 @@ describe("update", () => {
|
||||
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips draft tag payload and resolves setup asset from stable latest release", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("skips draft tag payload and resolves setup asset from stable latest release", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
const requestedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
@@ -350,8 +768,8 @@ describe("update", () => {
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it("blocks installer start when SHA256 digest mismatches", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("blocks installer start when SHA256 digest mismatches", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("mismatch-setup.exe")) {
|
||||
@@ -379,8 +797,8 @@ describe("update", () => {
|
||||
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
|
||||
});
|
||||
|
||||
it("blocks installer start when no digest can be resolved", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("blocks installer start when no digest can be resolved", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("unsigned-setup.exe")) {
|
||||
@@ -408,8 +826,8 @@ describe("update", () => {
|
||||
expect(result.message).toMatch(/digest|integrit|sha/i);
|
||||
});
|
||||
|
||||
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
const digestSha512Hex = sha512Hex(executablePayload);
|
||||
const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");
|
||||
const requestedUrls: string[] = [];
|
||||
@@ -479,8 +897,8 @@ describe("update", () => {
|
||||
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
|
||||
const executablePayload = createExecutablePayload();
|
||||
const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64");
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
@@ -547,7 +965,7 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
it("emits install progress events while downloading and launching update", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const executablePayload = createExecutablePayload();
|
||||
const digest = sha256Hex(executablePayload);
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
@@ -595,7 +1013,7 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
it("keeps the application running when Windows rejects the installer process", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const executablePayload = createExecutablePayload();
|
||||
const digest = sha256Hex(executablePayload);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(executablePayload, {
|
||||
status: 200,
|
||||
|
||||
Reference in New Issue
Block a user