import { once } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { Worker } from "node:worker_threads"; import { afterEach, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { foldPostCoreFinalizeIntoResult, runPostCoreFinalizeAfterGatewayUpdate, } from "../../infra/update-post-core-finalize.js"; import { createUpdateRun, finishUpdateRun, getUpdateRun, listUpdateRuns, } from "../../infra/update-run-ledger.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; import { defaultRuntime, ExitError } from "../../runtime.js"; import { classifyUpdateOutcome } from "../../shared/update-outcome.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { VERSION } from "../../version.js"; import { updateFinalizeCommand } from "./update-command-finalize.js"; import { admitUpdateCommandRun } from "./update-command-run.js"; import { updateCommand } from "./update-command.js"; import { UpdateFinalizationLifecycle } from "./update-finalization-lifecycle.js"; import { updateRepairCommand } from "./update-repair-command.js"; const fixture = vi.hoisted(() => ({ root: "" })); vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => ({ readCommand: async () => null }), })); vi.mock("./shared.js", async (importOriginal) => ({ ...(await importOriginal()), resolveUpdateRoot: async () => fixture.root, })); const dirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { closeOpenClawStateDatabaseForTest(); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); function state() { fixture.root = dirs.make("update-contention-"); vi.stubEnv("OPENCLAW_STATE_DIR", fixture.root); vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(fixture.root, "openclaw.json")); vi.stubEnv("OPENCLAW_UPDATE_RUN_ID", undefined); vi.stubEnv("OPENCLAW_UPDATE_RUN_HANDOFF", undefined); vi.stubEnv("OPENCLAW_POST_CORE_UPDATE", undefined); fs.writeFileSync( path.join(fixture.root, "package.json"), JSON.stringify({ name: "openclaw", version: VERSION }), ); const previous = createUpdateRun({ trigger: "cli" }); finishUpdateRun(previous.runId, { status: "succeeded" }); const before = getUpdateRun(previous.runId); closeOpenClawStateDatabaseForTest(); return { filename: resolveOpenClawStateSqlitePath(), before }; } it.each(["dry-run", "update", "repair"] as const)( "admits %s after a writer exceeds the general SQLite wait", async (mode) => { const { filename, before } = state(); const writer = new Worker( ` const { parentPort, workerData } = require('node:worker_threads'); const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(workerData); db.exec('BEGIN IMMEDIATE'); parentPort.postMessage('locked'); setTimeout(() => { db.exec('ROLLBACK'); db.close(); }, 6_000); `, { eval: true, workerData: filename, execArgv: [] }, ); try { await once(writer, "message"); const runId = mode === "repair" ? new UpdateFinalizationLifecycle(true, undefined, () => {}).attachLedger(true) : ( await admitUpdateCommandRun({ opts: { dryRun: mode === "dry-run" }, root: fixture.root, }) ).runId; expect(getUpdateRun(runId)).toMatchObject({ status: "running" }); expect(getUpdateRun(before!.runId)).toEqual(before); } finally { await writer.terminate(); } }, 20_000, ); it.each(["dry-run", "update", "repair"] as const)( "reports exhausted %s admission as recoverable and preserves history", async (mode) => { const { filename, before } = state(); const writer = new DatabaseSync(filename); const json = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); writer.exec("BEGIN IMMEDIATE"); try { const opts = { yes: true, json: true, timeout: "1" }; await expect( mode === "repair" ? updateRepairCommand(opts) : updateCommand({ ...opts, dryRun: mode === "dry-run" }), ).rejects.toMatchObject({ name: "ExitError", code: 0 }); expect(json).toHaveBeenCalledWith( expect.objectContaining({ status: "skipped", reason: "update-ledger-busy", notes: [expect.stringContaining("busy")], }), ); const result = json.mock.calls.at(-1)?.[0]; expect(result).not.toHaveProperty("runId"); if (mode === "dry-run") { expect(result).toMatchObject({ dryRun: true, notes: [expect.stringContaining("busy")] }); } expect(classifyUpdateOutcome({ status: "skipped", reason: "update-ledger-busy" })).toBe( "noop", ); expect(listUpdateRuns()).toEqual([before]); } finally { writer.exec("ROLLBACK"); writer.close(); } }, ); it("keeps exhausted hidden finalization deferred across the parent completion boundary", async () => { const { filename, before } = state(); const writer = new DatabaseSync(filename); const json = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); const result: UpdateRunResult = { status: "ok", mode: "git", root: fixture.root, steps: [], durationMs: 0, }; writer.exec("BEGIN IMMEDIATE"); try { let exitCode: number | undefined; const outcome = await runPostCoreFinalizeAfterGatewayUpdate({ result, resolveEntrypoint: async () => path.join(fixture.root, "openclaw.mjs"), spawnFinalize: async () => { try { await updateFinalizeCommand({ yes: true, json: true, timeout: "1" }); throw new Error("Finalization unexpectedly completed"); } catch (error) { if (!(error instanceof ExitError)) { throw error; } exitCode = error.code; return { code: error.code, stdout: JSON.stringify(json.mock.calls.at(-1)?.[0]) }; } }, }); expect(exitCode).toBe(1); expect(json).toHaveBeenCalledWith( expect.objectContaining({ status: "skipped", mode: "finalize", reason: "update-ledger-busy", }), ); expect(outcome).toMatchObject({ status: "skipped", reason: "update-ledger-busy" }); expect(foldPostCoreFinalizeIntoResult(result, outcome)).toMatchObject({ status: "skipped", reason: "update-ledger-busy", }); expect(listUpdateRuns()).toEqual([before]); } finally { writer.exec("ROLLBACK"); writer.close(); } });