Files
maka/scripts/verify-windows-installer-lifecycle.mjs
T
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

511 lines
18 KiB
JavaScript

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { access, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, join, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseProductReleaseVersion } from './release-version.mjs';
import { runCommand } from './verify-packaged-app.mjs';
import { powerShellLiteral, verifyPackagedWindowsApp } from './verify-windows-x64.mjs';
const uninstallExecutableName = 'Uninstall Maka.exe';
const temporaryCleanupRetries = 20;
const temporaryCleanupRetryDelayMs = 250;
const pollingProbeTimeoutMs = 10_000;
export function installerVersion(path) {
const match = basename(path).match(/^Maka-(.+)-win-x64\.exe$/u);
if (!match) {
throw new Error(`Cannot infer a release version from ${basename(path)}.`);
}
try {
parseProductReleaseVersion(match[1]);
} catch {
throw new Error(`Cannot infer a release version from ${basename(path)}.`);
}
return match[1];
}
function delay(milliseconds) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
}
export async function listInstalledProcesses(
installDirectory,
{ run = runCommand, timeoutMs = pollingProbeTimeoutMs } = {},
) {
const root = `${resolve(installDirectory)}${sep}`;
const script = String.raw`
$root = [IO.Path]::GetFullPath(${powerShellLiteral(root)})
$matches = @(
Get-CimInstance Win32_Process | ForEach-Object {
if ($_.ExecutablePath) {
$path = [IO.Path]::GetFullPath($_.ExecutablePath)
if ($path.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) {
[PSCustomObject]@{ processId = $_.ProcessId; name = $_.Name; path = $path }
}
}
}
)
$matches | ConvertTo-Json -Compress
`;
// Bounded: this probe runs inside polling loops whose deadline is the
// authority. An unbounded WMI query that wedges would otherwise turn a
// 60-second wait into the workflow timeout with no diagnostic.
const { stdout } = await run(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{
timeoutMs,
},
);
if (!stdout.trim()) return [];
const parsed = JSON.parse(stdout);
return Array.isArray(parsed) ? parsed : [parsed];
}
export async function waitForInstalledProcessesToExit(
installDirectory,
{
listProcesses = listInstalledProcesses,
timeoutMs = 60_000,
pollIntervalMs = 1_000,
sleep = delay,
} = {},
) {
const deadline = Date.now() + timeoutMs;
let lastProbeError;
for (;;) {
// A failed enumeration is retried, never treated as "no processes":
// this wait exists to prove exit, so an unreadable state must keep
// polling and fail at the deadline, not pass or die on one hiccup.
let processes;
try {
processes = await listProcesses(installDirectory, {
timeoutMs: remainingProbeBudget(deadline),
});
lastProbeError = undefined;
} catch (error) {
lastProbeError = error;
}
if (processes && processes.length === 0) return;
if (Date.now() >= deadline) {
if (lastProbeError) {
throw new Error(
`Could not enumerate installed Maka processes within ${timeoutMs}ms: ` +
`${lastProbeError.message}`,
{ cause: lastProbeError },
);
}
const summary = processes.map(({ processId, name }) => `${name} (${processId})`).join(', ');
throw new Error(
`Installed Maka processes did not exit within ${timeoutMs}ms: ${summary || '<unknown>'}.`,
);
}
await sleep(pollIntervalMs);
}
}
/**
* Wait for a named executable to appear among the installation's processes.
* The same probe-tolerance contract as the exit wait above, in the opposite
* direction: one transient WMI failure is one bad probe, not a verdict (run
* 32340493254 failed the relaunch wait on a single wedged enumeration while
* the upgraded app may already have been running). The deadline is the
* authority; the last probe error is evidence only when no later enumeration
* succeeds.
*/
export async function waitForInstalledProcessAppearance(
installDirectory,
executableName,
{
listProcesses = listInstalledProcesses,
timeoutMs = 120_000,
pollIntervalMs = 1_000,
sleep = delay,
} = {},
) {
const deadline = Date.now() + timeoutMs;
let lastProbeError;
for (;;) {
try {
const matched = (
await listProcesses(installDirectory, {
timeoutMs: remainingProbeBudget(deadline),
})
).filter(
(processInfo) => basename(processInfo.path).toLowerCase() === executableName.toLowerCase(),
);
lastProbeError = undefined;
if (matched.length > 0) return matched;
} catch (error) {
lastProbeError = error;
}
if (Date.now() >= deadline) {
throw new Error(
`${executableName} did not appear among installed processes within ${timeoutMs}ms.` +
`${lastProbeError ? `\nlast probe error: ${lastProbeError.message}` : ''}`,
lastProbeError ? { cause: lastProbeError } : undefined,
);
}
await sleep(pollIntervalMs);
}
}
/**
* Stop every process running from the installation directory and prove they
* are gone. The kill is the mechanism, not the assertion: exit 128 (the tree
* was already gone) and a taskkill that overruns its own bound (observed on
* wedged runners, run 32378497920, while the target still died) are
* tolerated, because the authoritative check is the exit wait, which fails
* with the live process list if anything from the install tree still runs.
* One policy for the main path and cleanup — run 32378497920's cleanup hit
* the same stale-PID exit 128 through a second, stricter copy of this loop.
*/
export async function terminateInstalledProcesses(
installDirectory,
{
run = runCommand,
listProcesses = listInstalledProcesses,
waitForExit = waitForInstalledProcessesToExit,
enumerationTimeoutMs = 60_000,
pollIntervalMs = 1_000,
sleep = delay,
} = {},
) {
const deadline = Date.now() + enumerationTimeoutMs;
const enumerate = (directory, options = {}) => listProcesses(directory, { run, ...options });
let processes;
for (;;) {
try {
processes = await enumerate(installDirectory, {
timeoutMs: remainingProbeBudget(deadline),
});
break;
} catch (error) {
if (Date.now() >= deadline) {
throw new Error(
`Could not enumerate installed Maka processes within ${enumerationTimeoutMs}ms: ` +
`${error.message}`,
{ cause: error },
);
}
await sleep(pollIntervalMs);
}
}
const killErrors = [];
for (const processInfo of processes) {
try {
await run('taskkill', ['/PID', String(processInfo.processId), '/T', '/F'], {
timeoutMs: 30_000,
});
} catch (error) {
killErrors.push(error);
}
}
try {
await waitForExit(installDirectory, { listProcesses: enumerate });
} catch (exitError) {
if (killErrors.length === 0) throw exitError;
throw new AggregateError(
[exitError, ...killErrors],
'Installed Maka processes did not exit after taskkill failures.',
{ cause: exitError },
);
}
}
export async function waitUntilMissing(
path,
{ probe = access, timeoutMs = 30_000, pollIntervalMs = 250 } = {},
) {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
await probe(path);
} catch (error) {
if (error?.code === 'ENOENT') return;
throw error;
}
if (Date.now() >= deadline) {
throw new Error(`Installer cleanup did not remove ${path} within ${timeoutMs}ms.`);
}
await delay(pollIntervalMs);
}
}
/**
* Every HKCU `DisplayVersion` registered under the Maka display name, joined
* with commas ('' when none). One PowerShell scan, shared by the harnesses
* that assert on — or wait out — the uninstall registration.
*/
export async function readUninstallDisplayVersions({
run = runCommand,
timeoutMs = pollingProbeTimeoutMs,
} = {}) {
// electron-builder's default uninstallDisplayName is
// "${productName} ${version}" (NsisTarget: UNINSTALL_DISPLAY_NAME), so the
// registered DisplayName is "Maka 0.1.11", never the bare product name. The
// trailing space in the wildcard keeps other products starting with "Maka"
// out; the bare-name arm covers a configured uninstallDisplayName without a
// version, should one ever be set.
const script = String.raw`
$entries = Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' |
ForEach-Object { Get-ItemProperty $_.PSPath } |
Where-Object { $_.DisplayName -eq 'Maka' -or $_.DisplayName -like 'Maka *' }
@($entries | ForEach-Object { $_.DisplayVersion }) -join ','
`;
// Bounded for the same reason as listInstalledProcesses: the enclosing
// waits own the deadline, so one stalled registry query must fail this
// probe, not the whole lane.
const { stdout } = await run(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{
timeoutMs,
},
);
return stdout.trim();
}
/**
* An NSIS uninstall is not over when the files are gone. Launched without
* `_?=`, "Uninstall Maka.exe" copies itself to %TEMP% and detaches; the copy
* removes $INSTDIR first and deletes the uninstall/install registry keys as
* its LAST action (uninstaller.nsh: RMDir /r, then shortcuts, SHChangeNotify
* and app-data handling, then DeleteRegKey), which on a busy runner lands tens
* of seconds after `waitUntilMissing` saw the files disappear. Anything that
* installs into that window gets its freshly written registration deleted by
* the stale uninstaller — observed as an empty uninstall entry two steps
* later. The uninstall registration's disappearance is the near-final
* signal, not the final one: the uninstaller deletes the uninstall key at
* uninstaller.nsh:250 and the install key at :254, so when the registration
* vanishes the detached copy still has one registry deletion left. A fresh
* install writes both keys after this wait returns, so the residual window
* is the width of two adjacent registry calls — accepted, and stated here
* so the premise is re-checked if the uninstaller's ordering ever changes.
*/
export async function waitForUninstallRegistrationToClear({
run = runCommand,
timeoutMs = 120_000,
pollIntervalMs = 2_000,
} = {}) {
const deadline = Date.now() + timeoutMs;
let lastProbeError;
for (;;) {
// One stalled or failed probe is tolerated and retried — the deadline
// below is the authority, and a transient WMI/registry hiccup must not
// end a wait that owns two minutes of budget.
let versions;
try {
versions = await readUninstallDisplayVersions({
run,
timeoutMs: remainingProbeBudget(deadline),
});
lastProbeError = undefined;
} catch (error) {
lastProbeError = error;
}
if (versions === '') return;
if (Date.now() >= deadline) {
if (lastProbeError) {
throw new Error(
`Could not read the Maka uninstall registration within ${timeoutMs}ms: ` +
`${lastProbeError.message}`,
{ cause: lastProbeError },
);
}
throw new Error(
`A Maka uninstall registration (DisplayVersion ${JSON.stringify(versions)}) is still ` +
`present after ${timeoutMs}ms — a detached uninstaller has not finished, or an ` +
`earlier step leaked an installation.`,
);
}
await delay(pollIntervalMs);
}
}
export async function completeInstalledApplicationUninstall(
installDirectory,
uninstaller,
{
run = runCommand,
requirePath = access,
waitForMissing = waitUntilMissing,
waitForRegistration = waitForUninstallRegistrationToClear,
} = {},
) {
let uninstallerExists = true;
try {
await requirePath(uninstaller);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
uninstallerExists = false;
}
if (uninstallerExists) {
await run(uninstaller, ['/S'], { timeoutMs: 120_000 });
}
await waitForMissing(installDirectory);
await waitForRegistration({ run });
}
function remainingProbeBudget(deadline) {
return Math.max(1, Math.min(pollingProbeTimeoutMs, deadline - Date.now()));
}
export async function verifyWindowsInstallerLifecycle(
inputPath,
previousInputPath,
{
platform = process.platform,
makeTemporaryDirectory = () => mkdtemp(join(tmpdir(), 'maka-installer-lifecycle-')),
run = runCommand,
verifyApp = verifyPackagedWindowsApp,
requirePath = access,
waitForMissing = waitUntilMissing,
waitForProcessesToExit = waitForInstalledProcessesToExit,
remove = rm,
resolvePath = resolve,
} = {},
) {
if (platform !== 'win32') {
throw new Error('Windows installer lifecycle verification requires Windows.');
}
if (!inputPath) {
throw new Error('Usage: npm run verify:windows-installer -- <path-to-exe>');
}
const installer = resolvePath(inputPath);
if (!installer.endsWith('.exe')) {
throw new Error(`Expected the NSIS installer .exe, found ${basename(installer)}.`);
}
await requirePath(installer);
const previousInstaller = previousInputPath ? resolvePath(previousInputPath) : undefined;
if (previousInstaller) {
installerVersion(previousInstaller);
await requirePath(previousInstaller);
}
const temporaryDirectory = await makeTemporaryDirectory();
const installDirectory = join(temporaryDirectory, 'installed');
const smokeDirectory = join(temporaryDirectory, 'smoke');
const uninstaller = join(installDirectory, uninstallExecutableName);
let installationStarted = false;
let uninstallCompleted = false;
let primaryError;
try {
installationStarted = true;
if (previousInstaller) {
const previousVersion = installerVersion(previousInstaller);
console.log(
`[verify-windows-installer] installing previous version ${previousVersion} into ${installDirectory}`,
);
await run(previousInstaller, ['/S', `/D=${installDirectory}`], { timeoutMs: 120_000 });
await requirePath(uninstaller);
console.log('[verify-windows-installer] verifying the previous installed application');
await verifyApp(installDirectory, {
workingDirectory: smokeDirectory,
expectedVersion: previousVersion,
artifactContract: 'upgrade-baseline',
});
console.log('[verify-windows-installer] waiting for previous-version processes to exit');
await waitForProcessesToExit(installDirectory);
console.log(`[verify-windows-installer] upgrading to ${installerVersion(installer)}`);
} else {
console.log(`[verify-windows-installer] installing into ${installDirectory}`);
}
// NSIS requires /D to be the final argument. spawn passes it directly, so
// spaces in a runner path do not need shell quoting.
await run(installer, ['/S', `/D=${installDirectory}`], { timeoutMs: 120_000 });
await requirePath(uninstaller);
console.log('[verify-windows-installer] verifying the installed application');
await verifyApp(installDirectory, { workingDirectory: smokeDirectory });
console.log('[verify-windows-installer] waiting for installed processes to exit');
await waitForProcessesToExit(installDirectory);
console.log('[verify-windows-installer] uninstalling');
// The registration's disappearance, not the files', is the uninstall's
// final action; the next verify step in this job installs immediately and
// must not race the detached uninstaller's DeleteRegKey.
await completeInstalledApplicationUninstall(installDirectory, uninstaller, {
run,
requirePath,
waitForMissing,
});
uninstallCompleted = true;
console.log('[verify-windows-installer] lifecycle verified');
return { installer, installDirectory };
} catch (error) {
primaryError = error;
throw error;
} finally {
const cleanupErrors = [];
if (installationStarted && !uninstallCompleted) {
let installedProcessesExited = false;
try {
await waitForProcessesToExit(installDirectory);
installedProcessesExited = true;
} catch (error) {
cleanupErrors.push(error);
}
if (installedProcessesExited) {
try {
await completeInstalledApplicationUninstall(installDirectory, uninstaller, {
run,
requirePath,
waitForMissing,
});
} catch (error) {
cleanupErrors.push(error);
}
}
}
try {
await remove(temporaryDirectory, {
recursive: true,
force: true,
maxRetries: temporaryCleanupRetries,
retryDelay: temporaryCleanupRetryDelayMs,
});
} catch (error) {
cleanupErrors.push(error);
}
if (cleanupErrors.length > 0) {
const cleanupFailure = new AggregateError(
cleanupErrors,
'Installer verifier cleanup failed.',
);
if (!primaryError) throw cleanupFailure;
if (primaryError instanceof Error && primaryError.cause === undefined) {
primaryError.cause = cleanupFailure;
}
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await verifyWindowsInstallerLifecycle(process.argv[2], process.argv[3]);
console.log(`Verified installer lifecycle for ${result.installer}`);
}