fix(update): continue after baseline fingerprint timeouts (#144758)

* fix(update): tolerate baseline package fingerprint timeouts

Keep the full package scan bounded, but record a recoverable warning when
it times out. Verify retained and restored package identity/version and
launchers when the baseline fingerprint is unavailable.

Stop hashing at the observed file size while preserving before/after
metadata checks, removing one redundant EOF read per file.

Release-note context: npm updates continue after baseline fingerprint
scan timeouts and report the reduced rollback verification as a warning.
Refs #143767.

* fix(update): retain fingerprint warnings after rollback

Let shared completion persist result steps before recording the terminal
rolled-back outcome. Keep the original failure reason and downtime.

Release-note context: rollback fingerprint advisories remain visible in
update status and reports after the update process exits.
Refs #143767.
This commit is contained in:
Peter Steinberger 2026-09-11 03:26:32 -07:00 • committed by GitHub
parent d9ad56cbe3
commit f50d4ce71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 294 additions and 63 deletions

View file

@ -44,6 +44,13 @@ reported cause, then run the warning's exact cleanup command or
that cannot boot or pass readiness still block completion. See
[Status and history](/cli/update/status-and-history) to inspect recorded warnings.
The baseline package fingerprint is best effort. If its bounded scan times out,
the update records a warning and continues with the retained package copy.
Rollback then verifies the restored directory identity, package version, and
affected launchers, and records that full fingerprint verification was unavailable.
A timeout alone does not fail the update or rollback; detected changes to the
retained copy still refuse restoration.
Interrupting a fresh local update before activation records a failed,
`interrupted` history entry while its installation owner is still held.
An interrupted update is not a successful update or a verified rollback.

View file

@ -32,14 +32,24 @@ import {
withUpdatePreviewSignals,
} from "./update-command-run.js";
import * as servicePlan from "./update-command-service-plan.js";
import { publishUpdateCommandTerminalResult } from "./update-command-terminal.js";
const dirs = useAutoCleanupTempDirTracker(afterEach);
it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", (exitCode) => {
it.each([
{ kind: "package-post-install-doctor", name: "openclaw doctor", exitCode: 0 },
{ kind: "package-post-install-doctor", name: "openclaw doctor", exitCode: 86 },
{ kind: "recoverable-maintenance", name: "global install swap", exitCode: 0 },
] as const)("persists and surfaces $kind warnings (exit $exitCode)", ({ kind, name, exitCode }) => {
const env = { OPENCLAW_STATE_DIR: dirs.make("update-warning-ledger-") };
const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env };
const message = "Skipped derived cache cleanup: permission denied. Run openclaw doctor --fix.";
const message =
kind === "recoverable-maintenance"
? "baseline package fingerprint incomplete after 30 s; rollback will be verified by the retained package copy"
: "Skipped derived cache cleanup: permission denied. Run openclaw doctor --fix.";
const otherWarning =
"Skipped legacy cache cleanup: read-only directory. Run openclaw doctor --fix.";
kind === "recoverable-maintenance"
? "Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version."
: "Skipped legacy cache cleanup: read-only directory. Run openclaw doctor --fix.";
const result = completeUpdateCommandRun(
{
status: "ok",
@ -47,12 +57,12 @@ it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", (
durationMs: 1,
steps: [
{
name: "openclaw doctor",
command: "openclaw doctor --fix",
name,
command: name,
cwd: "/tmp/update-fixture",
durationMs: 1,
exitCode,
advisory: { kind: "package-post-install-doctor", message },
advisory: { kind, message },
warnings: [message, otherWarning],
},
],
@ -65,12 +75,12 @@ it.each([0, 86])("persists and surfaces successful Doctor warnings (exit %s)", (
status: "succeeded",
steps: expect.arrayContaining([
expect.objectContaining({
step: "warning:openclaw doctor",
step: `warning:${name}`,
status: "completed",
detail: message,
}),
expect.objectContaining({
step: "warning:openclaw doctor:2",
step: `warning:${name}:2`,
status: "completed",
detail: otherWarning,
}),
@ -85,6 +95,60 @@ afterEach(() => {
vi.restoreAllMocks();
});
it("persists fingerprint warnings before closing a rolled-back run", () => {
const env = { OPENCLAW_STATE_DIR: dirs.make("rollback-fingerprint-warning-") };
const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env };
const warnings = [
"baseline package fingerprint incomplete after 30 s; rollback will be verified by the retained package copy",
"Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version.",
];
vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
const result = publishUpdateCommandTerminalResult(
{ opts: { json: true, run }, ownedManagedUpdateEnv: env },
{
status: "error",
mode: "npm",
reason: "doctor-failed",
before: { version: "1.0.0" },
after: { version: "1.0.0" },
recovery: {
serviceRestartSafe: true,
packageRollbackVerified: true,
service: "healthy",
version: "1.0.0",
},
durationMs: 50,
steps: [
{
name: "global install rollback",
command: "restore",
cwd: env.OPENCLAW_STATE_DIR,
durationMs: 1,
exitCode: 0,
advisory: { kind: "recoverable-maintenance", message: warnings.join("\n") },
warnings,
},
],
},
{ rolledBack: true, downtimeMs: 25 },
);
expect(result).toMatchObject({ status: "error", reason: "doctor-failed" });
const recorded = getUpdateRun(run.runId, { env })!;
expect(recorded).toMatchObject({
status: "rolled-back",
reason: "doctor-failed",
downtimeMs: 25,
});
expect(recorded.steps).toEqual(
expect.arrayContaining(
warnings.map((detail) => expect.objectContaining({ status: "completed", detail })),
),
);
for (const warning of warnings) {
expect(renderUpdateRunReport(recorded).markdown).toContain(warning);
}
});
it("presents committed steps without reopening the ledger for display", () => {
const env = { OPENCLAW_STATE_DIR: dirs.make("update-progress-committed-") };
const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env };

View file

@ -337,7 +337,7 @@ export function createUpdateRunProgress(
export function completeUpdateCommandRun(
result: UpdateRunResult,
run: UpdateCommandOptions["run"],
downtimeMs?: number,
completion: { rolledBack?: boolean; downtimeMs?: number } = {},
): UpdateRunResult {
if (!run) {
return result;
@ -402,15 +402,16 @@ export function completeUpdateCommandRun(
finishUpdateRun(
run.runId,
{
status:
normalized.status === "ok"
status: completion.rolledBack
? "rolled-back"
: normalized.status === "ok"
? "succeeded"
: normalized.status === "error"
? "failed"
: "skipped",
reason: normalized.reason,
after: normalized.after,
downtimeMs,
downtimeMs: completion.downtimeMs,
},
recordOptions,
);

View file

@ -4,11 +4,7 @@ import {
type ControlPlaneUpdateSentinelMetaFile,
} from "../../infra/update-control-plane-sentinel.js";
import { verifyPackageUpdateRecovery } from "../../infra/update-global.js";
import {
finishUpdateRun,
getUpdateRun,
recordUpdateRunPhase,
} from "../../infra/update-run-ledger.js";
import { getUpdateRun, recordUpdateRunPhase } from "../../infra/update-run-ledger.js";
import { assertUpdateRecoveryAdmission } from "../../infra/update-run-recovery-admission.js";
import { readCurrentGitUpdateRecovery } from "../../infra/update-runner-git-recovery.js";
import type { UpdateRunResult, UpdateStepResult } from "../../infra/update-runner.js";
@ -245,16 +241,7 @@ export function publishUpdateCommandTerminalResult(
outcome: { rolledBack: boolean; downtimeMs?: number },
): UpdateRunResult {
const nextAction = recordUpdateResultNextAction(params, input);
const run = params.opts.run;
const { downtimeMs } = outcome;
if (run && outcome.rolledBack) {
finishUpdateRun(
run.runId,
{ status: "rolled-back", reason: input.reason, after: input.after, downtimeMs },
{ env: run.env },
);
}
const result = completeUpdateCommandRun(input, run, downtimeMs);
const result = completeUpdateCommandRun(input, params.opts.run, outcome);
printResult(result, params.opts, { nextAction });
return result;
}

View file

@ -16,6 +16,13 @@ const log = createSubsystemLogger("update/package-integrity");
let readerSequence = 0;
export type PackageIntegrityFingerprint = { digest: string; identity: string; version: string };
export type PackageDirectoryIdentity = Pick<PackageIntegrityFingerprint, "identity" | "version">;
export class PackageIntegrityTimeoutError extends Error {
constructor(readonly budgetMs: number) {
super("Package rollback verification timed out");
}
}
export type PackageRootIntegrityFingerprint =
| { kind: "directory"; tree: PackageIntegrityFingerprint }
@ -121,7 +128,7 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) {
)
.catch(() => {});
}
throw new Error("Package rollback verification timed out");
throw new PackageIntegrityTimeoutError(budget);
}
return value;
}
@ -170,18 +177,17 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) {
}
const hash = createHash("sha256");
const buffer = Buffer.allocUnsafe(64 * 1024);
const size = Number(stat.size);
let position = 0;
while (true) {
// The final stat detects growth; an extra EOF read costs one OS call per file.
while (position < size) {
const { bytesRead } = await read(() =>
handle.read(buffer, 0, Math.min(buffer.length, remainingBytes - position + 1), position),
handle.read(buffer, 0, Math.min(buffer.length, size - position), position),
);
if (bytesRead === 0) {
break;
throw new Error("Package rollback file changed while reading");
}
position += bytesRead;
if (position > remainingBytes) {
throw new Error("Package rollback verification byte limit exceeded");
}
hash.update(buffer.subarray(0, bytesRead));
}
if (!unchanged(stat, await read(() => handle.stat({ bigint: true })))) {
@ -306,6 +312,21 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) {
return { kind: "link", metadata: metadata(stat).slice(0, -1), target };
}
async function directoryIdentity(root: string): Promise<PackageDirectoryIdentity | null> {
const stat = await read(() => fs.lstat(root, { bigint: true }));
if (stat.isSymbolicLink()) {
return null;
}
if (!stat.isDirectory() || stat.ino === 0n) {
throw new Error("Package rollback filesystem identity is unavailable");
}
const version = await read(() => readPackageVersion(root, { maxBytes: MAX_MANIFEST_BYTES }));
if (!version || !unchanged(stat, await read(() => fs.lstat(root, { bigint: true })))) {
throw new Error("Package rollback identity changed or version is unavailable");
}
return { identity: identity(stat), version };
}
async function launcher(file: string): Promise<string> {
const stat = await read(() => fs.lstat(file, { bigint: true }));
const contents = stat.isSymbolicLink()
@ -337,5 +358,5 @@ export function createPackageIntegrityReader(timeoutMs = MAX_SCAN_MS) {
}
}
return { tree, rootEntry, launcher, exists, entries, observe };
return { tree, rootEntry, directoryIdentity, launcher, exists, entries, observe };
}

View file

@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
import { formatErrorMessage } from "./errors.js";
import {
createPackageIntegrityReader,
type PackageDirectoryIdentity,
type PackageRootIntegrityFingerprint,
} from "./package-update-integrity.js";
@ -63,6 +64,7 @@ export async function verifyNpmRootRecovery(
fromBackup: boolean;
hadPackage: boolean;
previousRoot: PackageRootIntegrityFingerprint | undefined;
previousIdentity?: PackageDirectoryIdentity;
targetSwapRoot: string;
shims: readonly { destination: string; backup: string | null; fingerprint?: string }[];
},
@ -73,11 +75,13 @@ export async function verifyNpmRootRecovery(
await reader.observe(fromBackup ? "retained" : "restored", async () => {
if (
hadPackage
? !previousRoot ||
!isDeepStrictEqual(
await reader.rootEntry(root, targetSwapRoot, previousRoot.kind),
previousRoot,
)
? previousRoot
? !isDeepStrictEqual(
await reader.rootEntry(root, targetSwapRoot, previousRoot.kind),
previousRoot,
)
: !params.previousIdentity ||
!isDeepStrictEqual(await reader.directoryIdentity(root), params.previousIdentity)
: !fromBackup && (await reader.exists(root))
) {
throw new Error(

View file

@ -7,6 +7,7 @@ import { createDeferredCore } from "../shared/deferred.js";
import { withTestDir } from "../test-helpers/temp-dir.js";
import { swapStagedPackageInstall, type PackageUpdateTransaction } from "./package-update-swap.js";
import { createPackageSwapFixture } from "./package-update-swap.test-support.js";
import { updateRunStepsFromResultStep } from "./update-run-step.js";
afterEach(() => {
setLoggerOverride(null);
@ -29,6 +30,88 @@ function captureReaderLogs() {
}
describe("package verification bounds", () => {
it.each(["activation", "rollback", "changed identity", "changed version"] as const)(
"handles %s after the baseline fingerprint times out",
async (outcome) => {
await withTestDir({ prefix: "openclaw-fingerprint-advisory-" }, async (base) => {
const { params, packageRoot, launcher } = await createPackageSwapFixture(base);
const original = await fs.stat(packageRoot);
const open = fs.open.bind(fs);
const blocked = createDeferredCore();
let entered = false;
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
if (!entered && String(args[0]) === path.join(packageRoot, "dist", "index.js")) {
entered = true;
await blocked.promise;
}
return open(...args);
});
let transaction: PackageUpdateTransaction | undefined;
const beforeActivate = vi.fn();
try {
const result = await swapStagedPackageInstall({
...params,
timeoutMs: 200,
beforeActivate,
onTransaction: (value) => {
transaction = value;
},
});
expect(entered).toBe(true);
expect(result.status, result.step.stderrTail ?? "").toBe("committed");
expect(beforeActivate).toHaveBeenCalledOnce();
expect(result.step.advisory?.message).toContain(
"baseline package fingerprint incomplete",
);
expect(updateRunStepsFromResultStep(result.step)).toContainEqual(
expect.objectContaining({ step: "warning:global install swap", status: "completed" }),
);
expect(await fs.readFile(launcher, "utf8")).toBe("candidate launcher\n");
if (!transaction) {
throw new Error("Missing package transaction");
}
if (outcome === "changed identity" || outcome === "changed version") {
if (outcome === "changed identity") {
await fs.rename(transaction.backupRoot, `${transaction.backupRoot}.original`);
await fs.cp(`${transaction.backupRoot}.original`, transaction.backupRoot, {
recursive: true,
});
} else {
await fs.writeFile(
path.join(transaction.backupRoot, "package.json"),
'{"version":"3.0.0"}',
);
}
const refused = await transaction.rollback(() => {});
expect(refused.exitCode).toBe(1);
expect(refused.advisory).toBeUndefined();
expect(refused.stderrTail).toContain("retained package tree changed");
expect(await fs.readFile(launcher, "utf8")).toBe("candidate launcher\n");
await expect(fs.stat(transaction.backupRoot)).resolves.toBeDefined();
return;
}
if (outcome === "rollback") {
const restored = await transaction.rollback(() => {});
expect(restored).toMatchObject({ exitCode: 0, activePackageRoot: packageRoot });
expect(restored.advisory?.message).toContain("fingerprint verification unavailable");
expect(restored.stderrTail ?? "").not.toMatch(/unverified|verification failed/);
expect(await fs.readFile(launcher, "utf8")).toBe("old launcher\n");
expect(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")).toContain(
'"version":"1.0.0"',
);
const actual = await fs.stat(packageRoot);
expect([actual.dev, actual.ino]).toEqual([original.dev, original.ino]);
}
expect(
await transaction.complete({ activationVerified: outcome === "activation" }, () => {}),
).toBeUndefined();
} finally {
blocked.resolve();
}
});
},
);
it.each([1024 * 1024 + 1, 1024 * 1024 * 1024 + 1])(
"rejects manifest growth to %i bytes without attempting an oversized metadata allocation",
async (size) => {
@ -95,8 +178,13 @@ describe("package verification bounds", () => {
expect((await transactions[0]!.rollback(() => {})).exitCode).toBe(0);
await expect(fs.readFile(manifest, "utf8")).resolves.toHaveLength(1024 * 1024);
const finished = observations.filter((record) => record.event === "reader-settled");
expect(finished.map((record) => record.phase)).toEqual(["baseline", "retained", "restored"]);
expect(new Set(finished.map((record) => record.readerId)).size).toBe(3);
expect(finished.map((record) => record.phase)).toEqual([
"baseline",
"baseline",
"retained",
"restored",
]);
expect(new Set(finished.map((record) => record.readerId)).size).toBe(4);
for (const record of finished) {
expect(record).toMatchObject({ outcome: "completed", budgetMs: 30_000, pendingIo: 0 });
expect(record.timeoutObservedAtMonotonicMs).toBeUndefined();
@ -179,6 +267,9 @@ describe("package verification bounds", () => {
const close = vi.spyOn(handle, "close");
const read = vi.spyOn(handle, "read");
const open = vi.spyOn(fs, "open").mockImplementation(async (...args) => {
if (String(args[0]) !== path.join(packageRoot, "dist", "index.js")) {
return realOpen(...args);
}
if (operation === "open") {
return late.promise;
}
@ -197,13 +288,21 @@ describe("package verification bounds", () => {
onLiveMutation,
timeoutMs: 40,
});
expect(result.status).toBe("failed");
expect(result.step.stderrTail).toContain("timed out");
expect(result.status).toBe("committed");
expect(result.step.advisory?.message).toContain(
"baseline package fingerprint incomplete",
);
expect(Date.now() - started).toBeLessThan(2000);
expect(beforeActivate).not.toHaveBeenCalled();
expect(onLiveMutation).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledTimes(1);
const baseline = observations.filter((record) => record.phase === "baseline");
expect(beforeActivate).toHaveBeenCalledOnce();
expect(onLiveMutation).toHaveBeenCalledOnce();
expect(
open.mock.calls.filter(
([file]) => String(file) === path.join(packageRoot, "dist", "index.js"),
),
).toHaveLength(1);
const baseline = observations.filter(
(record) => record.readerId === observations[0]?.readerId,
);
expect(baseline).toHaveLength(2);
const [begin, settled] = baseline;
expect(begin).toMatchObject({ event: "reader-started", budgetMs: 40 });
@ -230,8 +329,8 @@ describe("package verification bounds", () => {
}
await expect(
fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
).resolves.toContain('"version":"1.0.0"');
await expect(fs.readFile(launcher, "utf8")).resolves.toBe("old launcher\n");
).resolves.toContain('"version":"2.0.0"');
await expect(fs.readFile(launcher, "utf8")).resolves.toBe("candidate launcher\n");
} finally {
late.resolve(handle);
await handle.close();
@ -289,7 +388,7 @@ describe("package verification bounds", () => {
const result = await swapStagedPackageInstall({ ...params, timeoutMs: 40 });
// Preserve the existing best-effort close policy, but report its timeout.
expect(result.status).toBe("committed");
expect(observations.find((record) => record.event === "reader-settled")).toMatchObject({
expect(observations.findLast((record) => record.event === "reader-settled")).toMatchObject({
phase: "baseline",
outcome: "timed-out",
pendingIo: 1,

View file

@ -18,7 +18,9 @@ import {
} from "./package-update-filesystem.js";
import {
createPackageIntegrityReader,
PackageIntegrityTimeoutError,
readPackageVersionIfPresent,
type PackageDirectoryIdentity,
type PackageRootIntegrityFingerprint,
} from "./package-update-integrity.js";
import {
@ -82,6 +84,7 @@ export async function swapStagedPackageInstall(params: {
: params.installTarget.packageRoot;
const targetSwapRoot = native?.liveProjectRoot ?? targetPackageRoot;
const stagedSwapRoot = native?.projectRoot ?? params.stage.packageRoot;
const warnings: string[] = [];
const step = (
exitCode: number,
stdoutTail: string | null,
@ -94,6 +97,15 @@ export async function swapStagedPackageInstall(params: {
exitCode,
stdoutTail,
stderrTail,
...(exitCode === 0 && warnings.length > 0
? {
advisory: {
kind: "recoverable-maintenance" as const,
message: warnings.join("\n"),
},
warnings: [...warnings],
}
: {}),
});
if (!targetLayout || !targetPackageRoot || !targetSwapRoot) {
return {
@ -115,6 +127,7 @@ export async function swapStagedPackageInstall(params: {
let previousVersion: string | null = null;
let previousDistFiles: string[] | undefined;
let previousRoot: PackageRootIntegrityFingerprint | undefined;
let previousIdentity: PackageDirectoryIdentity | undefined;
let rootLink: ReturnType<typeof createNpmPackageRootLinkLifecycle> | undefined;
let packageBackedUp = false;
let displacedCandidateRoot: string | undefined;
@ -132,7 +145,7 @@ export async function swapStagedPackageInstall(params: {
let activationCompleted = false;
const verifyNpmRecovery = (root: string, fromBackup: boolean) =>
verifyNpmRootRecovery(
{ root, fromBackup, hadPackage, previousRoot, targetSwapRoot, shims },
{ root, fromBackup, hadPackage, previousRoot, previousIdentity, targetSwapRoot, shims },
params.timeoutMs,
);
const restoreSwap = async (assertCurrent = () => {}): Promise<string[]> => {
@ -191,7 +204,15 @@ export async function swapStagedPackageInstall(params: {
await verifyNpmRecovery(targetSwapRoot, false);
// Returning to absence cannot establish a verified previous runtime.
packageRollbackVerified =
hadPackage && previousRoot?.kind === "directory" && messages.length === 0;
hadPackage &&
(previousRoot?.kind === "directory" ||
(!previousRoot && previousIdentity !== undefined)) &&
messages.length === 0;
if (packageRollbackVerified && !previousRoot) {
warnings.push(
"Package fingerprint verification unavailable; rollback verified by the retained package copy's directory identity and version.",
);
}
if (previousRoot?.kind === "link" && messages.length === 0) {
messages.push(
`${rollback.length > 0 ? "Restored" : "Verified"} the npm package link and affected launchers; external checkout runtime integrity is unverified.`,
@ -263,11 +284,29 @@ export async function swapStagedPackageInstall(params: {
? await readPackageVersionIfPresent(params.installTarget.packageRoot)
: null;
if (hadPackage && !native) {
// Unreadable or unbounded rollback material must fail while the old
// package is still live, before beforeActivate may stop its service.
previousRoot = await baseline.rootEntry(targetSwapRoot);
previousVersion = previousRoot.kind === "directory" ? previousRoot.tree.version : null;
if (previousRoot.kind === "link") {
try {
previousRoot = await baseline.rootEntry(targetSwapRoot);
} catch (error) {
if (!(error instanceof PackageIntegrityTimeoutError)) {
throw error;
}
// Capture the identity before mutation even when the full walk exhausted its budget.
previousIdentity =
(await createPackageIntegrityReader(params.timeoutMs).directoryIdentity(
targetSwapRoot,
)) ?? undefined;
if (!previousIdentity) {
throw error;
}
warnings.push(
`baseline package fingerprint incomplete after ${error.budgetMs / 1000} s; rollback will be verified by the retained package copy`,
);
}
previousVersion =
previousRoot?.kind === "directory"
? previousRoot.tree.version
: (previousIdentity?.version ?? null);
if (previousRoot?.kind === "link") {
rootLink = createNpmPackageRootLinkLifecycle({
liveRoot: targetSwapRoot,
backupRoot,
@ -282,6 +321,8 @@ export async function swapStagedPackageInstall(params: {
(await collectPackageDistInventory(params.installTarget.packageRoot!));
}
packageRollbackVerified = hadPackage && previousVersion !== null;
};
const readLaunchers = async (launcherReader: ReturnType<typeof createPackageIntegrityReader>) => {
await fs.mkdir(targetLayout.globalRoot, { recursive: true });
const shimNames = new Set([params.packageName, "openclaw"]);
const shimEntries =
@ -291,7 +332,7 @@ export async function swapStagedPackageInstall(params: {
await (
native
? fs.readdir(params.stage.layout.binDir)
: baseline.entries(params.stage.layout.binDir)
: launcherReader.entries(params.stage.layout.binDir)
).catch((error: unknown) => {
if (hasErrnoCode(error, "ENOENT")) {
return [];
@ -312,13 +353,14 @@ export async function swapStagedPackageInstall(params: {
const destination = path.join(targetLayout.binDir, entry);
const backup = (await (native
? pathEntryExists(destination)
: baseline.exists(destination)))
: launcherReader.exists(destination)))
? path.join(shimBackupDir, entry)
: null;
const fingerprint = backup && !native ? await baseline.launcher(destination) : undefined;
const fingerprint =
backup && !native ? await launcherReader.launcher(destination) : undefined;
if (backup) {
await copyPathEntry(destination, backup);
if (!native && (await baseline.launcher(backup)) !== fingerprint) {
if (!native && (await launcherReader.launcher(backup)) !== fingerprint) {
throw new Error(`Package rollback launcher backup changed: ${destination}`);
}
}
@ -333,6 +375,9 @@ export async function swapStagedPackageInstall(params: {
};
try {
await (native ? readBaseline() : baseline.observe("baseline", readBaseline));
// The optional tree scan must not consume the launcher backup's deadline.
const launcherReader = createPackageIntegrityReader(params.timeoutMs);
await launcherReader.observe("baseline", () => readLaunchers(launcherReader));
// Validation and launcher backup finish while the old Gateway is serving.
// Only this boundary authorizes the orchestrator to suspend the service.
const assertProjectUnchanged = native
@ -536,7 +581,10 @@ export async function swapStagedPackageInstall(params: {
}
activePackageRoot = null;
packageBackedUp = true;
packageRollbackVerified = native !== undefined || previousRoot?.kind === "directory";
packageRollbackVerified =
native !== undefined ||
previousRoot?.kind === "directory" ||
previousIdentity !== undefined;
}
rollback.push(async (assertCurrent) => {
if (!native && hadPackage) {