openclaw / src /cli /update-cli /update-command-contention.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
6.9 kB
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<typeof import("./shared.js")>()),
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();
}
});