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:
Sucukdeluxe
2026-08-21 12:19:20 +02:00
parent c375491c88
commit bc86c29d57
8 changed files with 108 additions and 127 deletions
+8
View File
@@ -4,6 +4,14 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased]
## [2.0.53] - 2026-08-21
### In-app updates
- Started the verified NSIS installer directly before application shutdown so Windows Server and RDP process-job cleanup cannot terminate an intermediate PowerShell launcher.
- Kept the running application open when Windows rejects the installer process instead of reporting a successful update and quitting without an installer.
- Delegated the complete process handoff to the installer with a 60-second graceful wait and a verified post-termination wait before replacing application files.
## [2.0.52] - 2026-08-21
### Rolling account statistics
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.52",
"version": "2.0.53",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-debrid-downloader",
"version": "2.0.52",
"version": "2.0.53",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.52",
"version": "2.0.53",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+15 -3
View File
@@ -9,11 +9,23 @@
Goto update_ready
${endif}
IntOp $R1 $R1 + 1
${if} $R1 >= 40
${if} $R1 >= 300
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"`
Pop $R0
Sleep 500
Goto update_ready
StrCpy $R2 0
update_force_wait:
!insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 != 0
Goto update_force_ready
${endif}
IntOp $R2 $R2 + 1
${if} $R2 >= 150
Quit
${endif}
Sleep 200
Goto update_force_wait
update_force_ready:
Goto update_ready
${endif}
Sleep 200
Goto update_wait
+9 -35
View File
@@ -896,35 +896,6 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string
export function buildInstallerLaunchArgs(): string[] {
return ["/S", "--updated", "--force-run"];
}
function quotePowerShellLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function buildDeferredInstallerLaunch(
targetPath: string,
currentProcessId: number = process.pid,
systemRoot: string = process.env.SystemRoot || "C:\\Windows"
): { command: string; args: string[] } {
const installerArgs = buildInstallerLaunchArgs().map(quotePowerShellLiteral).join(",");
const script = [
"$deadline=(Get-Date).AddMinutes(2)",
`while ((Get-Process -Id ${Math.max(1, Math.floor(currentProcessId))} -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Milliseconds 200 }`,
`Start-Process -FilePath ${quotePowerShellLiteral(targetPath)} -ArgumentList @(${installerArgs}) -WindowStyle Hidden`
].join("; ");
return {
command: path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
args: [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-EncodedCommand",
Buffer.from(script, "utf16le").toString("base64")
]
};
}
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
const safeRepo = normalizeUpdateRepo(repo);
@@ -1126,16 +1097,19 @@ export async function installLatestUpdate(
message: "Starte stille Update-Installation",
});
const deferredLaunch = buildDeferredInstallerLaunch(targetPath);
const child = childProcess.spawn(deferredLaunch.command, deferredLaunch.args, {
const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.once("error", (spawnError) => {
logger.error(`Update-Launcher Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
});
child.unref();
await new Promise<void>((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", (spawnError) => {
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
reject(spawnError);
});
});
child.unref();
emitProgress(onProgress, {
stage: "done", percent: 100, downloadedBytes: 0, totalBytes: null,
+3
View File
@@ -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", () => {
+8 -53
View File
@@ -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
View File
@@ -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", () => {