openclaw / src /cli /gateway-cli /host-lifecycle.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
9.12 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prepareHostedGatewayStop, type HostedGatewayStop } from "../../daemon/hosted-stop.js";
import { getGatewayProcessInstanceId } from "../../gateway/process-instance.js";
import {
armGatewaySuspendHandoff,
consumeGatewaySuspendHandoff,
prepareGatewaySuspend,
resumeGatewaySuspend,
} from "../../infra/gateway-suspend-coordinator.js";
import { scheduleSafeGatewayRestart } from "../../infra/restart-coordinator.js";
import { createGatewayHostLifecycle } from "./host-lifecycle.js";
vi.mock("../../daemon/hosted-stop.js", () => ({ prepareHostedGatewayStop: vi.fn() }));
vi.mock("../../infra/restart-coordinator.js", () => ({ scheduleSafeGatewayRestart: vi.fn() }));
describe("Gateway host lifecycle authority", () => {
let current = true;
let serving = true;
const acceptStop = vi.fn();
const execute = vi.fn<HostedGatewayStop["execute"]>();
const dispose = vi.fn<HostedGatewayStop["dispose"]>();
const owners: ReturnType<typeof createGatewayHostLifecycle>[] = [];
const owner = () => {
const host = createGatewayHostLifecycle({
isCurrent: () => current,
isServing: () => serving,
acceptStop,
processOwner: { ownsProcessLifecycle: true, supervisor: null },
});
owners.push(host);
return host;
};
beforeEach(() => {
vi.resetAllMocks();
dispose.mockResolvedValue(undefined);
current = serving = true;
execute.mockResolvedValue({ outcome: "accepted" });
vi.mocked(prepareHostedGatewayStop).mockResolvedValue({ execute, dispose });
acceptStop.mockImplementation(() => {
serving = false;
});
});
afterEach(async () => {
for (const host of owners.splice(0)) {
await host.retire();
}
});
it("reports an already-running start and a local scheduled restart without native discovery", async () => {
const host = owner();
const guard = vi.fn();
await expect(host.capability.request("start", guard)).resolves.toEqual({
ok: true,
value: { outcome: "already-running" },
});
await expect(host.capability.request("restart", guard)).resolves.toEqual({
ok: true,
value: { outcome: "scheduled" },
});
expect(prepareHostedGatewayStop).not.toHaveBeenCalled();
expect(scheduleSafeGatewayRestart).toHaveBeenCalledExactlyOnceWith({
reason: "gateway.restart.safe",
delayMs: 0,
});
expect(guard).toHaveBeenCalledTimes(2);
});
it("transfers only the accepted stop across teardown, without awaiting native completion", async () => {
const host = owner();
await expect(host.capability.request("stop", () => {})).resolves.toEqual({
ok: true,
value: { outcome: "scheduled" },
});
expect(acceptStop).toHaveBeenCalledOnce();
expect(execute).not.toHaveBeenCalled();
await expect(host.capability.request("start", () => {})).resolves.toMatchObject({ ok: false });
await expect(host.finishStop()).resolves.toEqual({ outcome: "accepted" });
expect(execute).toHaveBeenCalledOnce();
await expect(host.finishStop()).resolves.toEqual({ outcome: "retired" });
});
it.each(["retired", "replaced", "closed", "approval revoked"])(
"rejects after awaited preparation when %s",
async (change) => {
const host = owner();
let approve = true;
let resolvePreparation!: (stop: HostedGatewayStop) => void;
vi.mocked(prepareHostedGatewayStop).mockImplementation(
() =>
new Promise((resolve) => {
resolvePreparation = resolve;
}),
);
const request = host.capability.request("stop", () => {
if (!approve) {
throw new Error("approval revoked");
}
});
const retirement = change === "retired" ? host.retire() : undefined;
if (change === "replaced") {
current = false;
}
if (change === "closed") {
serving = false;
}
if (change === "approval revoked") {
approve = false;
}
resolvePreparation({ execute, dispose });
await expect(request).resolves.toMatchObject({ ok: false });
await retirement;
expect(acceptStop).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledOnce();
},
);
it("keeps serving on preparation failure and preserves a concurrent preparation owner", async () => {
const host = owner();
let rejectPreparation!: (error: Error) => void;
vi.mocked(prepareHostedGatewayStop).mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectPreparation = reject;
}),
);
const first = host.capability.request("stop", () => {});
await expect(host.capability.request("stop", () => {})).resolves.toMatchObject({ ok: false });
await expect(host.capability.request("stop", () => {})).resolves.toMatchObject({ ok: false });
expect(prepareHostedGatewayStop).toHaveBeenCalledOnce();
rejectPreparation(new Error("native permission denied"));
await expect(first).resolves.toEqual({ ok: false, error: "native permission denied" });
expect(acceptStop).not.toHaveBeenCalled();
await expect(host.capability.request("start", () => {})).resolves.toMatchObject({ ok: true });
});
it("retires accepted native authority on preemption", async () => {
const host = owner();
await host.capability.request("stop", () => {});
await host.retire();
await expect(host.finishStop()).resolves.toEqual({ outcome: "retired" });
expect(execute).not.toHaveBeenCalled();
});
it("does not carry an armed lease into another host iteration in the same process", async () => {
const first = owner();
const processInstanceId = getGatewayProcessInstanceId();
const lease = prepareGatewaySuspend({
requestId: "host-iteration-handoff",
drain: true,
pauseScheduling: () => {},
resumeScheduling: () => {},
inspect: { getRootRequests: () => 1, getTerminalPersistence: () => 0 },
});
if (lease.status !== "draining" || !first.capability.externalRestart) {
throw new Error("missing owned lease");
}
try {
expect(
armGatewaySuspendHandoff({
suspensionId: lease.suspensionId,
owner: first.capability.externalRestart,
}).ok,
).toBe(true);
await first.retire();
const next = owner();
expect(getGatewayProcessInstanceId()).toBe(processInstanceId);
expect(first.capability.externalRestart.isCurrent()).toBe(false);
expect(next.capability.externalRestart?.isCurrent()).toBe(true);
expect(consumeGatewaySuspendHandoff(first.capability.externalRestart)).toEqual({
ok: true,
value: false,
});
expect(consumeGatewaySuspendHandoff(next.capability.externalRestart)).toEqual({
ok: true,
value: false,
});
} finally {
resumeGatewaySuspend(lease.suspensionId);
}
});
it.each(["preparing", "accepted"])(
"joins %s executor cleanup before retiring the owner",
async (phase) => {
const host = owner();
let finishClose!: () => void;
dispose.mockImplementation(
() =>
new Promise((resolve) => {
finishClose = resolve;
}),
);
let finishPrepare!: (stop: HostedGatewayStop) => void;
if (phase === "preparing") {
vi.mocked(prepareHostedGatewayStop).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishPrepare = resolve;
}),
);
}
const request = host.capability.request("stop", () => {});
if (phase === "accepted") {
await request;
}
let retired = false;
const retirement = host.retire().then(() => {
retired = true;
});
if (phase === "preparing") {
finishPrepare({ execute, dispose });
}
await expect(host.capability.request("restart", () => {})).resolves.toMatchObject({
ok: false,
});
expect(retired).toBe(false);
expect(execute).not.toHaveBeenCalled();
finishClose();
await retirement;
await request;
expect(dispose).toHaveBeenCalledOnce();
},
);
it("joins an in-flight native inspection on retirement and discards its late result", async () => {
const host = owner();
let finishNative!: (result: { outcome: "accepted" }) => void;
execute.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishNative = resolve;
}),
);
await host.capability.request("stop", () => {});
const finishing = host.finishStop();
let retired = false;
const retirement = host.retire().then(() => {
retired = true;
});
try {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(retired).toBe(false);
current = false;
} finally {
finishNative({ outcome: "accepted" });
}
await expect(finishing).resolves.toEqual({ outcome: "retired" });
await retirement;
});
});