| import fs from "node:fs"; |
| import path from "node:path"; |
| import { afterEach, expect, it, vi } from "vitest"; |
| import { createDeferred } from "../../../test/helpers/promise.js"; |
| import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; |
| import * as temporaryRoot from "../../infra/tmp-openclaw-dir.js"; |
| import { createManagedHandoffLeaseStore } from "../../infra/update-managed-service-handoff-lease.js"; |
| import { |
| createUpdateRun, |
| getUpdateRun, |
| recordUpdateRunPhase, |
| } from "../../infra/update-run-ledger.js"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; |
| import { |
| withUpdateCommandExecutor, |
| withUpdateCommandExecutorChild, |
| } from "./update-command-executor.js"; |
| import { withUpdateCommandTerminalResult } from "./update-command-terminal.js"; |
|
|
| const dirs = useAutoCleanupTempDirTracker(afterEach); |
| afterEach(() => { |
| vi.useRealTimers(); |
| closeOpenClawStateDatabaseForTest(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| it("records an activation timeout when executor settlement remains pending", async () => { |
| const root = fs.realpathSync(dirs.make("update-activation-")); |
| const temporary = path.join(root, "private-tmp"); |
| fs.mkdirSync(temporary, { mode: 0o700 }); |
| vi.spyOn(temporaryRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(temporary); |
| const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") }; |
| const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env }; |
| const output = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); |
| vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); |
| const entered = createDeferred(); |
| const release = createDeferred(); |
| let childWork: Promise<unknown> | undefined; |
| let outcome: unknown; |
| let assertCurrent: (() => void) | undefined; |
| const budget = 10 * 60_000; |
| vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); |
| const running = withUpdateCommandTerminalResult( |
| async (registerRun) => { |
| registerRun(run); |
| await withUpdateCommandExecutor(run.runId, async (executor) => { |
| const fence = await executor.enter(root, { activationTimeoutMs: budget }); |
| assertCurrent = fence.assertCurrent; |
| recordUpdateRunPhase(run.runId, "activating", undefined, { env }); |
| childWork = withUpdateCommandExecutorChild(fence, root, async () => { |
| entered.resolve(); |
| await release.promise; |
| }); |
| void childWork.catch(() => {}); |
| await entered.promise; |
| |
| }); |
| }, |
| { json: true }, |
| ).catch((error: unknown) => { |
| outcome = error; |
| }); |
| try { |
| await entered.promise; |
| await vi.advanceTimersByTimeAsync(budget - 1); |
| expect(getUpdateRun(run.runId, { env })?.status).toBe("running"); |
| expect(assertCurrent).toBeDefined(); |
| await vi.advanceTimersByTimeAsync(budget + 1); |
| await running; |
| expect(outcome).toMatchObject({ result: { reason: "update-activation-timeout" } }); |
| expect(assertCurrent).toThrow("activation"); |
| expect(getUpdateRun(run.runId, { env })).toMatchObject({ |
| phase: "finished", |
| status: "failed", |
| reason: "update-activation-timeout", |
| }); |
| expect(output).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| reason: "update-activation-timeout", |
| status: "error", |
| }), |
| ); |
| expect(createManagedHandoffLeaseStore().read(root).kind).toBe("current"); |
| await expect( |
| withUpdateCommandExecutor(run.runId, (other) => other.enter(root)), |
| ).rejects.toThrow("Another update executor"); |
| } finally { |
| release.resolve(); |
| await childWork?.catch(() => {}); |
| await running; |
| } |
| }); |
|
|
| it.each([false, true])( |
| "checks the activation deadline after synchronous work (expired: %s)", |
| async (expired) => { |
| const root = fs.realpathSync(dirs.make("update-activation-clock-")); |
| const temporary = path.join(root, "private-tmp"); |
| fs.mkdirSync(temporary, { mode: 0o700 }); |
| vi.spyOn(temporaryRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(temporary); |
| vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); |
| const work = withUpdateCommandExecutor("clock-probe", async (executor) => { |
| await executor.enter(root, { activationTimeoutMs: 60_000 }); |
| |
| vi.setSystemTime(Date.now() + (expired ? 60_001 : 59_999)); |
| return "completed"; |
| }); |
| if (expired) { |
| await expect(work).rejects.toMatchObject({ reason: "update-activation-timeout" }); |
| } else { |
| await expect(work).resolves.toBe("completed"); |
| } |
| expect(createManagedHandoffLeaseStore().read(root)).toEqual({ kind: "absent" }); |
| expect(vi.getTimerCount()).toBe(0); |
| }, |
| ); |
|
|
| it.each([undefined, 48 * 60 * 60_000])( |
| "keeps activation alive for measured state and caller allowance %s", |
| async (callerTimeoutMs) => { |
| const { resolveUpdateFinalizationTimeoutMs } = |
| await import("../../infra/update-finalization-budget.js"); |
| const root = fs.realpathSync(dirs.make("update-activation-size-")); |
| const database = path.join(root, "agent.sqlite"); |
| const descriptor = fs.openSync(database, "w"); |
| fs.ftruncateSync(descriptor, 2 * 1024 ** 3); |
| fs.closeSync(descriptor); |
| const budget = await resolveUpdateFinalizationTimeoutMs(callerTimeoutMs, { |
| databases: [{ path: database }], |
| env: { ...process.env, OPENCLAW_STATE_DIR: root }, |
| }); |
| const legacyBudget = Math.max(30 * 60_000, (callerTimeoutMs ?? 0) * 6); |
| vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); |
| await expect( |
| withUpdateCommandExecutor("measured-activation", async (executor) => { |
| const fence = await executor.enter(root, { activationTimeoutMs: budget }); |
| vi.setSystemTime(Date.now() + (callerTimeoutMs ?? legacyBudget + 1)); |
| fence.assertCurrent(); |
| return "completed"; |
| }), |
| ).resolves.toBe("completed"); |
| expect(createManagedHandoffLeaseStore().read(root)).toEqual({ kind: "absent" }); |
| }, |
| ); |
|
|