release: prepare v2.0.53 updater handoff
Launch the verified NSIS installer directly before application shutdown, wait for Windows to confirm process creation, and keep the application open on spawn failure. Move process waiting into NSIS with a bounded graceful and post-force termination check, and cover the handoff with updater regression tests.
This commit is contained in:
@@ -40,6 +40,9 @@ describe("Multi-Debrid-Downloader product metadata", () => {
|
||||
expect(installer).toContain("${isUpdated}");
|
||||
expect(installer).toContain("FIND_PROCESS");
|
||||
expect(installer).toContain("taskkill /f /im");
|
||||
expect(installer).toContain("$R1 >= 300");
|
||||
expect(installer).toContain("update_force_wait:");
|
||||
expect(installer).toContain("update_force_ready:");
|
||||
});
|
||||
|
||||
it("moves the legacy user data directory without losing runtime state", () => {
|
||||
|
||||
@@ -1,58 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildDeferredInstallerLaunch } from "../src/main/update";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
describe("update process handoff", () => {
|
||||
it("starts the verified installer before shutdown and delegates waiting to NSIS", () => {
|
||||
const updateSource = fs.readFileSync(new URL("../src/main/update.ts", import.meta.url), "utf8");
|
||||
const launchBlock = updateSource.slice(updateSource.indexOf('message: "Starte stille Update-Installation"'), updateSource.indexOf('message: "Update wird im Hintergrund installiert'));
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!fs.existsSync(filePath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error("marker timeout");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
describe.runIf(process.platform === "win32")("deferred update launch", () => {
|
||||
it("starts the installer only after the previous application process exits", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-update-launch-"));
|
||||
tempDirs.push(root);
|
||||
const installerPath = path.join(root, "installer.cmd");
|
||||
const markerPath = path.join(root, "started.txt");
|
||||
fs.writeFileSync(installerPath, `@echo off\r\n> "${markerPath}" echo started\r\n`, "utf8");
|
||||
const previous = spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "ping 127.0.0.1 -n 3 > nul"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
});
|
||||
if (!previous.pid) {
|
||||
throw new Error("previous process missing pid");
|
||||
}
|
||||
const deferred = buildDeferredInstallerLaunch(installerPath, previous.pid);
|
||||
const launcher = spawn(deferred.command, deferred.args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
previous.once("exit", () => resolve());
|
||||
previous.once("error", reject);
|
||||
});
|
||||
await waitForFile(markerPath, 5_000);
|
||||
expect(fs.readFileSync(markerPath, "utf8").trim()).toBe("started");
|
||||
|
||||
launcher.unref();
|
||||
expect(launchBlock).toContain("childProcess.spawn(targetPath, buildInstallerLaunchArgs()");
|
||||
expect(launchBlock).not.toContain("powershell.exe");
|
||||
expect(launchBlock).not.toContain("buildDeferredInstallerLaunch");
|
||||
});
|
||||
});
|
||||
|
||||
+62
-33
@@ -2,15 +2,20 @@ import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => {
|
||||
const unref = vi.fn();
|
||||
const once = vi.fn((_event: string, _handler: (...args: unknown[]) => void) => ({
|
||||
unref
|
||||
}));
|
||||
const spawn = vi.fn(() => ({
|
||||
once,
|
||||
unref
|
||||
}));
|
||||
const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => {
|
||||
const unref = vi.fn();
|
||||
const child: { once: ReturnType<typeof vi.fn>; unref: ReturnType<typeof vi.fn> } = {
|
||||
once: vi.fn(),
|
||||
unref
|
||||
};
|
||||
const once = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === "spawn") {
|
||||
queueMicrotask(() => handler());
|
||||
}
|
||||
return child;
|
||||
});
|
||||
child.once = once;
|
||||
const spawn = vi.fn(() => child);
|
||||
return {
|
||||
spawnMock: spawn,
|
||||
unrefMock: unref,
|
||||
@@ -22,7 +27,7 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock
|
||||
}));
|
||||
|
||||
import { buildDeferredInstallerLaunch, buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { APP_VERSION } from "../src/main/constants";
|
||||
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
||||
|
||||
@@ -167,24 +172,6 @@ describe("update", () => {
|
||||
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
|
||||
});
|
||||
|
||||
it("defers the installer until the current application process has exited", () => {
|
||||
const launch = buildDeferredInstallerLaunch("C:\\Temp\\MDD Update\\setup.exe", 4242, "C:\\Windows");
|
||||
const encoded = launch.args.at(-1) || "";
|
||||
const script = Buffer.from(encoded, "base64").toString("utf16le");
|
||||
|
||||
expect(launch.command).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||
expect(launch.args.slice(0, -1)).toEqual([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-EncodedCommand"
|
||||
]);
|
||||
expect(script.indexOf("Get-Process -Id 4242")).toBeLessThan(script.indexOf("Start-Process -FilePath 'C:\\Temp\\MDD Update\\setup.exe'"));
|
||||
expect(script).toContain("@('/S','--updated','--force-run')");
|
||||
});
|
||||
|
||||
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const executableDigest = sha256Hex(executablePayload);
|
||||
@@ -559,7 +546,7 @@ describe("update", () => {
|
||||
expect(result.message).toMatch(/sha512|integrit|mismatch/i);
|
||||
});
|
||||
|
||||
it("emits install progress events while downloading and launching update", async () => {
|
||||
it("emits install progress events while downloading and launching update", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const digest = sha256Hex(executablePayload);
|
||||
|
||||
@@ -594,7 +581,7 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/powershell\.exe$/i), expect.arrayContaining(["-EncodedCommand"]), expect.objectContaining({
|
||||
expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/rd-update[\\/].*setup\.exe$/i), ["/S", "--updated", "--force-run"], expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
@@ -604,9 +591,51 @@ describe("update", () => {
|
||||
expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true);
|
||||
});
|
||||
});
|
||||
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the application running when Windows rejects the installer process", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const digest = sha256Hex(executablePayload);
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(executablePayload.length)
|
||||
}
|
||||
})) as typeof fetch;
|
||||
|
||||
const child: { once: ReturnType<typeof vi.fn>; unref: ReturnType<typeof vi.fn> } = {
|
||||
once: vi.fn(),
|
||||
unref: vi.fn()
|
||||
};
|
||||
child.once.mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === "error") {
|
||||
queueMicrotask(() => handler(new Error("spawn EACCES")));
|
||||
}
|
||||
return child;
|
||||
});
|
||||
spawnMock.mockReturnValueOnce(child);
|
||||
|
||||
const progressEvents: UpdateInstallProgress[] = [];
|
||||
const result = await installLatestUpdate("owner/repo", {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: `sha256:${digest}`
|
||||
}, (progress) => progressEvents.push(progress));
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toContain("spawn EACCES");
|
||||
expect(child.unref).not.toHaveBeenCalled();
|
||||
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(false);
|
||||
expect(progressEvents.at(-1)?.stage).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeUpdateRepo extended", () => {
|
||||
it("handles trailing slashes and extra path segments", () => {
|
||||
|
||||
Reference in New Issue
Block a user