fix(gateway): bound timed-out QR retention

This commit is contained in:
jesse-merhi
2026-08-13 05:36:37 +10:00
parent fbecf73b4f
commit 35baf62fd8
2 changed files with 84 additions and 0 deletions
@@ -1379,6 +1379,80 @@ describe("openclaw.chat", () => {
);
});
it("releases active QR capacity after an abandoned ownerless QR times out", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_800_000_000_000);
const runnerFinished = createDeferred();
const qrEngine = new SystemAgentChatEngine(
{
verifiedInference: requireVerifiedInferenceFixture(),
deps: requireVerifiedInferenceDeps(),
supportsQrCode: true,
},
{
wizardDependencies: {
runChannelSetupWizard: async (_channel, prompter, _beforePersistentApply, signal) => {
const owner = new Promise<void>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => reject(new Error("QR owner aborted", { cause: signal.reason })),
{ once: true },
);
});
try {
await prompter.qrCode?.({
title: "Link a device",
message: "Scan this QR code.",
text: "https://example.test/pair",
dismissed: owner,
});
} finally {
runnerFinished.resolve();
}
},
},
},
);
try {
await expect(qrEngine.handle("connect telegram")).resolves.toMatchObject({
step: { type: "qr" },
});
await vi.advanceTimersByTimeAsync(SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS);
await runnerFinished.promise;
await Promise.resolve();
expect(qrEngine.hasPendingQrCode()).toBe(true);
await vi.advanceTimersByTimeAsync(SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS + 1);
expect(qrEngine.hasPendingQrCode()).toBe(false);
const timedOutSession = seededSession({
engine: qrEngine,
lastUsedAt: 0,
ownerKey: "device:timed-out-owner",
});
const disposeTimedOut = vi.spyOn(qrEngine, "dispose");
const sessions = new Map<string, SystemAgentChatSession>([["timed-out", timedOutSession]]);
for (let index = 1; index < 8; index += 1) {
const protectedSession = seededSession({
lastUsedAt: index,
ownerKey: `device:protected-owner-${index}`,
});
vi.spyOn(protectedSession.engine, "hasPendingQrCode").mockReturnValue(true);
sessions.set(`protected-${index}`, protectedSession);
}
stubEngineOverview();
await expect(
callChat(makeContext(sessions), { sessionId: "new-session" }),
).resolves.toMatchObject({ ok: true });
expect(disposeTimedOut).toHaveBeenCalledOnce();
expect(sessions.has("timed-out")).toBe(false);
expect(sessions.has("new-session")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("bounds retained QR recovery sessions without evicting their live leases", async () => {
const sessions = new Map<string, SystemAgentChatSession>();
for (let index = 0; index < 16; index += 1) {
+10
View File
@@ -568,6 +568,12 @@ export class ChatWizardHost {
SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS,
);
bridge.expiryTimer.unref?.();
this.retainPassiveQrAfterSettlement(bridge, stepId);
}
private retainPassiveQrAfterSettlement(bridge: ActiveWizardBridge, stepId: string): void {
// The runner can still apply state after its displayed QR is gone. Start the bounded
// recovery lease only once settlement makes owner eviction safe.
void bridge.session.whenSettled().then(() => {
if (this.bridge === bridge && bridge.passiveQrStepId === stepId) {
this.clearExpiry(bridge);
@@ -606,6 +612,10 @@ export class ChatWizardHost {
this.armRunnerExpiry(bridge, stepId);
return;
}
const stepId = bridge.step?.type === "qr" ? bridge.passiveQrStepId : undefined;
if (stepId !== undefined) {
this.retainPassiveQrAfterSettlement(bridge, stepId);
}
bridge.session.cancel();
// Keep a scrubbed marker until the next queued turn observes expiry.
bridge.qrExpired = true;