Files
openclaw/src/gateway/server-node-session-runtime.test.ts
T
xingzhou 186c25f7f7 fix(gateway): avoid stale wake delays after node re-pairing (#109647)
* fix(gateway): clear wake state on node removal

* fix(gateway): invalidate wake flows on node removal

* fix(gateway): preserve active wakes on disconnect

* fix(gateway): share node removal runtime cleanup

* fix(gateway): clean up pending node work reliably

* fix(gateway): bind node work to pairing generations

* fix(gateway): fence stale node pairing sessions

* fix(gateway): tighten node pairing ownership

* fix(gateway): close node pairing mutation races

* fix(gateway): bind node sessions to pairing generation

* fix(gateway): harden node pairing generation guards

* fix(gateway): close node pairing generation races

* fix(gateway): guard APNs transport ownership

* fix(gateway): revoke reapproved node transports

* fix(gateway): guard retired node lifecycles

* fix(gateway): preserve pending work across reconnects

* fix(gateway): bind node subscriptions to pairing generations

* fix(gateway): settle subscription fanout failures

* fix(gateway): fence pairing generation ownership

* fix(gateway): fence asynchronous node events

* fix(gateway): fence watch results and voice wake fanout

* fix(gateway): guard voice wake generation fanout

* fix(gateway): carry authenticated snapshot generation

* fix(gateway): bind node state to pairing generation

* fix(gateway): align generation guards with current base

* fix(gateway): enforce pairing-owned node lifecycle

* fix(gateway): fence detached voice session writes

* chore(deps): bump proxyline to 0.3.4

* fix(gateway): close pairing lifecycle races

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* fix(gateway): distinguish pending action mutation

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* test(gateway): align lifecycle coverage

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* test(skills): split remote reconciliation coverage

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* style: format pairing lifecycle changes

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-21 05:10:08 -07:00

220 lines
8.2 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import {
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
} from "./server-chat-state.js";
import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js";
import type { GatewayWsClient } from "./server/ws-types.js";
type TestSocket = {
bufferedAmount: number;
send: (payload: string) => void;
close: (code?: number, reason?: string) => void;
};
function makeGatewayWsClient(connId: string, socket: TestSocket): GatewayWsClient {
return {
socket: socket as unknown as GatewayWsClient["socket"],
connId,
usesSharedGatewayAuth: false,
connect: {
role: "node",
scopes: [],
client: {
id: "node-client",
version: "1.0.0",
platform: "macos",
mode: "node",
},
device: { id: "node-a" },
} as unknown as GatewayWsClient["connect"],
};
}
function createRuntime(
resolveCurrentPairingGeneration: () => Promise<string>,
broadcast = vi.fn(),
isPairingStateCurrent: NonNullable<
Parameters<typeof createGatewayNodeSessionRuntime>[0]["isPairingStateCurrent"]
> = (_nodeId, expected) =>
expected.identity === "identity-a" && expected.generation === "generation-a",
) {
return createGatewayNodeSessionRuntime({
broadcast,
resolveCurrentPairingState: async () => ({
identity: "identity-a",
generation: await resolveCurrentPairingGeneration(),
}),
isPairingStateCurrent,
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
});
}
function registerNode(
runtime: ReturnType<typeof createRuntime>,
connId: string,
pairingGeneration: string,
frames: string[],
) {
const socket: TestSocket = {
bufferedAmount: 0,
send: vi.fn((payload: string) => frames.push(payload)),
close: vi.fn(),
};
runtime.nodeRegistry.register(makeGatewayWsClient(connId, socket), {
pairingIdentity: "identity-a",
pairingGeneration,
});
}
describe("gateway node session runtime", () => {
test("forwards subscribed payload json without parsing it again", async () => {
const frames: string[] = [];
const runtime = createRuntime(async () => "generation-a");
registerNode(runtime, "conn-node-a", "generation-a", frames);
runtime.nodeSubscribe("node-a", "main", "conn-node-a");
const parseSpy = vi.spyOn(JSON, "parse");
try {
runtime.nodeSendToSession("main", "chat", { ok: true });
await vi.waitFor(() => expect(frames).toHaveLength(1));
expect(parseSpy).not.toHaveBeenCalled();
} finally {
parseSpy.mockRestore();
}
expect(JSON.parse(frames[0] ?? "{}")).toEqual({
type: "event",
event: "chat",
payload: { ok: true },
});
});
test("fences voice-wake updates by pairing generation while retaining operator broadcasts", async () => {
let currentPairingGeneration = "generation-a";
const resolveCurrentPairingGeneration = vi.fn(async () => currentPairingGeneration);
const broadcast = vi.fn();
const runtime = createRuntime(
resolveCurrentPairingGeneration,
broadcast,
(_nodeId, expected) =>
expected.identity === "identity-a" && expected.generation === currentPairingGeneration,
);
const frames: string[] = [];
registerNode(runtime, "conn-node-a", "generation-a", frames);
const send = vi.spyOn(runtime.nodeRegistry, "sendEventRawForPairingGeneration");
const routing = {
version: 1 as const,
defaultTarget: { mode: "current" as const },
routes: [],
updatedAtMs: 1,
};
runtime.broadcastVoiceWakeChanged(["openclaw"]);
runtime.broadcastVoiceWakeRoutingChanged(routing);
await vi.waitFor(() => expect(frames).toHaveLength(2));
currentPairingGeneration = "generation-b";
runtime.broadcastVoiceWakeChanged(["retired"]);
runtime.broadcastVoiceWakeRoutingChanged({ ...routing, updatedAtMs: 2 });
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(4));
expect(frames.map((frame) => JSON.parse(frame))).toEqual([
{ type: "event", event: "voicewake.changed", payload: { triggers: ["openclaw"] } },
{ type: "event", event: "voicewake.routing.changed", payload: { config: routing } },
]);
expect(broadcast).toHaveBeenCalledTimes(4);
});
test("fences generation-less voice-wake updates by authenticated pairing identity", async () => {
let pairingExists = true;
const broadcast = vi.fn();
const runtime = createGatewayNodeSessionRuntime({
broadcast,
resolveCurrentPairingState: async () =>
pairingExists ? { identity: "identity-a" } : undefined,
isPairingStateCurrent: (_nodeId, expected) =>
pairingExists && expected.identity === "identity-a",
sessionEventSubscribers: createSessionEventSubscriberRegistry(),
sessionMessageSubscribers: createSessionMessageSubscriberRegistry(),
});
const frames: string[] = [];
const socket: TestSocket = {
bufferedAmount: 0,
send: vi.fn((payload: string) => frames.push(payload)),
close: vi.fn(),
};
const client = makeGatewayWsClient("conn-node-a", socket);
runtime.nodeRegistry.register(client, { pairingIdentity: "identity-a" });
const send = vi.spyOn(runtime.nodeRegistry, "sendEventRawForPairingGeneration");
runtime.broadcastVoiceWakeChanged(["openclaw"]);
await vi.waitFor(() => expect(frames).toHaveLength(1));
pairingExists = false;
runtime.broadcastVoiceWakeChanged(["retired"]);
await vi.waitFor(() => expect(client.invalidated).toBe(true));
expect(send).not.toHaveBeenCalled();
expect(frames.map((frame) => JSON.parse(frame))).toEqual([
{ type: "event", event: "voicewake.changed", payload: { triggers: ["openclaw"] } },
]);
expect(client.invalidated).toBe(true);
expect(broadcast).toHaveBeenCalledTimes(2);
});
test("does not inherit subscriptions across a replacement pairing generation", async () => {
let currentPairingGeneration = "generation-a";
const runtime = createRuntime(async () => currentPairingGeneration);
const originalFrames: string[] = [];
registerNode(runtime, "conn-original", "generation-a", originalFrames);
runtime.nodeSubscribe("node-a", "main", "conn-original");
runtime.nodeSendToSession("main", "chat", { seq: 1 });
await vi.waitFor(() => expect(originalFrames).toHaveLength(1));
currentPairingGeneration = "generation-b";
runtime.nodeSendToSession("main", "chat", { seq: 2 });
await vi.waitFor(() => expect(runtime.nodeRegistry.get("node-a")).toBeUndefined());
expect(originalFrames).toHaveLength(1);
const replacementFrames: string[] = [];
registerNode(runtime, "conn-replacement", "generation-b", replacementFrames);
runtime.nodeSubscribe("node-a", "retired", "conn-original");
runtime.nodeSendToSession("retired", "chat", { seq: 3 });
expect(replacementFrames).toHaveLength(0);
runtime.nodeSubscribe("node-a", "main", "conn-replacement");
runtime.nodeSendToSession("main", "chat", { seq: 4 });
await vi.waitFor(() => expect(replacementFrames).toHaveLength(1));
const reconnectFrames: string[] = [];
registerNode(runtime, "conn-reconnect", "generation-b", reconnectFrames);
runtime.nodeSendToSession("main", "chat", { seq: 5 });
await vi.waitFor(() => expect(reconnectFrames).toHaveLength(1));
});
test("preserves subscriptions for an exact live pairing generation promotion", async () => {
let currentPairingGeneration = "generation-a";
const runtime = createRuntime(async () => currentPairingGeneration);
const frames: string[] = [];
registerNode(runtime, "conn-node-a", "generation-a", frames);
runtime.nodeSubscribe("node-a", "main", "conn-node-a");
currentPairingGeneration = "generation-b";
expect(
runtime.nodeRegistry.updateSurface(
"node-a",
{ commands: [] },
{
expectedConnId: "conn-node-a",
expectedPairingIdentity: "identity-a",
expectedPairingGeneration: "generation-a",
nextPairingGeneration: "generation-b",
},
),
).not.toBeNull();
runtime.nodeSendToSession("main", "chat", { ok: true });
await vi.waitFor(() => expect(frames).toHaveLength(1));
});
});