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
511 lines
18 KiB
JavaScript
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}`);
|
|
}
|