mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:01:34 +00:00
fix(ui): recover sessions and dashboards after prolonged outages (#115654)
* fix(ui): recover long-running sessions and dashboards * fix(ui): expose browser-safe gateway timers * refactor(ui): keep browser timer surface minimal * refactor(gateway): extract pending request state * fix(ci): refresh browser runtime and format baseline * fix(ci): exclude generated plugin outputs from targeted lint * fix(ui): bound long-running connection and pane lifecycles * refactor(ui): split long-running lifecycle regression coverage * fix(ui): bound long-running media and lint ownership * fix(security): preserve explicit workspace disable denylist * fix(ci): preserve plugin manifest fallback lint
This commit is contained in:
committed by
GitHub
parent
1ab48a71df
commit
fde4658bf6
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export * from "./protocol-client.js";
|
||||
export * from "./reconnect-policy.js";
|
||||
export * from "./session-projection.js";
|
||||
export * from "./session-subscriptions.js";
|
||||
export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js";
|
||||
export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS, resolveSafeTimeoutDelayMs } from "./timeouts.js";
|
||||
export * from "@openclaw/gateway-protocol/client-info";
|
||||
export * from "@openclaw/gateway-protocol/connect-error-details";
|
||||
export * from "@openclaw/gateway-protocol/gateway-error-details";
|
||||
|
||||
@@ -223,6 +223,97 @@ describe("GatewayClient", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ retirement: "event owner", firstListenerCalls: 0 },
|
||||
{ retirement: "first direct listener", firstListenerCalls: 1 },
|
||||
])(
|
||||
"does not deliver a retired frame after the $retirement closes its socket",
|
||||
({ retirement, firstListenerCalls }) => {
|
||||
const onEvent = vi.fn(() => {
|
||||
if (retirement === "event owner") {
|
||||
client.stop();
|
||||
}
|
||||
});
|
||||
const firstListener = vi.fn(() => {
|
||||
if (retirement === "first direct listener") {
|
||||
client.stop();
|
||||
}
|
||||
});
|
||||
const staleListener = vi.fn();
|
||||
const { client, connections } = createSyntheticGatewayProtocol({ onEvent });
|
||||
client.addEventListener(firstListener);
|
||||
client.addEventListener(staleListener);
|
||||
client.start();
|
||||
const connection = connections[0];
|
||||
if (!connection) {
|
||||
throw new Error("synthetic protocol connection missing");
|
||||
}
|
||||
|
||||
connection.handlers.message(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "board.command",
|
||||
payload: { command: "retired" },
|
||||
seq: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
expect(firstListener).toHaveBeenCalledTimes(firstListenerCalls);
|
||||
expect(staleListener).not.toHaveBeenCalled();
|
||||
expect(connection.close).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ replacement: "a new callback", reuseCallback: false },
|
||||
{ replacement: "the same callback", reuseCallback: true },
|
||||
])("does not revive a removed subscription replaced with $replacement", ({ reuseCallback }) => {
|
||||
const removedListener = vi.fn();
|
||||
const addedListener = reuseCallback ? removedListener : vi.fn();
|
||||
let removeListener = () => {};
|
||||
let isFirstEvent = true;
|
||||
const onEvent = vi.fn(() => {
|
||||
if (isFirstEvent) {
|
||||
isFirstEvent = false;
|
||||
removeListener();
|
||||
client.addEventListener(addedListener);
|
||||
}
|
||||
});
|
||||
const { client, connections } = createSyntheticGatewayProtocol({ onEvent });
|
||||
removeListener = client.addEventListener(removedListener);
|
||||
client.start();
|
||||
const connection = connections[0];
|
||||
if (!connection) {
|
||||
throw new Error("synthetic protocol connection missing");
|
||||
}
|
||||
|
||||
connection.handlers.message(
|
||||
JSON.stringify({ type: "event", event: "board.changed", payload: {}, seq: 1 }),
|
||||
);
|
||||
|
||||
expect(removedListener).not.toHaveBeenCalled();
|
||||
expect(addedListener).not.toHaveBeenCalled();
|
||||
|
||||
connection.handlers.message(
|
||||
JSON.stringify({ type: "event", event: "board.changed", payload: {}, seq: 2 }),
|
||||
);
|
||||
|
||||
expect(addedListener).toHaveBeenCalledOnce();
|
||||
if (!reuseCallback) {
|
||||
expect(removedListener).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// Calling the retired subscription's disposer cannot remove its replacement.
|
||||
removeListener();
|
||||
connection.handlers.message(
|
||||
JSON.stringify({ type: "event", event: "board.changed", payload: {}, seq: 3 }),
|
||||
);
|
||||
|
||||
expect(addedListener).toHaveBeenCalledTimes(2);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ recovery: "stops the socket", restart: false },
|
||||
{ recovery: "replaces the socket", restart: true },
|
||||
|
||||
24
packages/gateway-client/src/event-listeners.ts
Normal file
24
packages/gateway-client/src/event-listeners.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
type GatewayEventListener<TEvent> = (event: TEvent) => void;
|
||||
|
||||
/** Subscription identity prevents old frames and disposers from reviving callbacks. */
|
||||
export class GatewayEventListeners<TEvent> {
|
||||
private readonly listeners = new Map<GatewayEventListener<TEvent>, object>();
|
||||
|
||||
add(listener: GatewayEventListener<TEvent>): () => void {
|
||||
const subscription = this.listeners.get(listener) ?? {};
|
||||
this.listeners.set(listener, subscription);
|
||||
return () => {
|
||||
if (this.listeners.get(listener) === subscription) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): Array<[GatewayEventListener<TEvent>, object]> {
|
||||
return [...this.listeners];
|
||||
}
|
||||
|
||||
isCurrent(listener: GatewayEventListener<TEvent>, subscription: object): boolean {
|
||||
return this.listeners.get(listener) === subscription;
|
||||
}
|
||||
}
|
||||
12
packages/gateway-client/src/pending-request.ts
Normal file
12
packages/gateway-client/src/pending-request.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Owned settlement, cleanup, and timing state for one Gateway wire request. */
|
||||
export type GatewayPendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
expectFinal: boolean;
|
||||
acceptedNotified: boolean;
|
||||
onAccepted?: (payload: unknown) => void;
|
||||
cleanup?: () => void;
|
||||
unbounded: boolean;
|
||||
method: string;
|
||||
startedAtMs: number;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayProtocolClient, type GatewayProtocolSocketHandlers } from "./protocol-client.js";
|
||||
import { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js";
|
||||
|
||||
type HandshakeConnection = {
|
||||
handlers: GatewayProtocolSocketHandlers;
|
||||
send: ReturnType<typeof vi.fn<(data: string) => void>>;
|
||||
close: ReturnType<typeof vi.fn<(code?: number, reason?: string) => void>>;
|
||||
};
|
||||
|
||||
function createHandshakeClient(
|
||||
buildConnectPlan: () => Record<string, never> | Promise<Record<string, never>> = () => ({}),
|
||||
) {
|
||||
const connections: HandshakeConnection[] = [];
|
||||
let nextRequestId = 0;
|
||||
const client = new GatewayProtocolClient<Record<string, never>>({
|
||||
createSocket: (handlers) => {
|
||||
let open = true;
|
||||
const send = vi.fn<(data: string) => void>();
|
||||
const close = vi.fn<(code?: number, reason?: string) => void>((code, reason) => {
|
||||
open = false;
|
||||
handlers.close(code ?? 1000, reason ?? "");
|
||||
});
|
||||
connections.push({ handlers, send, close });
|
||||
return { isOpen: () => open, send, close };
|
||||
},
|
||||
createRequestId: () => `request-${++nextRequestId}`,
|
||||
buildConnectPlan,
|
||||
buildConnectParams: (plan) => plan,
|
||||
resolveClose: () => ({ retry: true, notify: true }),
|
||||
handshake: { mode: "require-challenge", timeoutMs: 100 },
|
||||
reconnect: { initialMs: 10, multiplier: 2, maxMs: 100 },
|
||||
});
|
||||
return { client, connections };
|
||||
}
|
||||
|
||||
function receiveConnectChallenge(connection: HandshakeConnection): void {
|
||||
connection.handlers.open();
|
||||
connection.handlers.message(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "synthetic-nonce" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GatewayProtocolClient connect handshake", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("reconnects when an open Gateway never responds to connect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, connections } = createHandshakeClient();
|
||||
client.start();
|
||||
const connection = connections[0];
|
||||
expect(connection).toBeDefined();
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
receiveConnectChallenge(connection);
|
||||
expect(connection.send).toHaveBeenCalledOnce();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
expect(connection.close).toHaveBeenCalledWith(4000, "connect timeout");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(connections).toHaveLength(2);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("retires device preparation that outlives the connect handshake", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolvePlan: (plan: Record<string, never>) => void = () => undefined;
|
||||
const plan = new Promise<Record<string, never>>((resolve) => {
|
||||
resolvePlan = resolve;
|
||||
});
|
||||
const { client, connections } = createHandshakeClient(() => plan);
|
||||
client.start();
|
||||
const connection = connections[0];
|
||||
expect(connection).toBeDefined();
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
receiveConnectChallenge(connection);
|
||||
expect(connection.send).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
expect(connection.close).toHaveBeenCalledWith(4000, "connect timeout");
|
||||
resolvePlan({});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(connection.send).not.toHaveBeenCalled();
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
isGatewayResponseFrame,
|
||||
} from "@openclaw/gateway-protocol/frame-guards";
|
||||
import { RetrySupervisor, sleepWithAbort } from "@openclaw/retry";
|
||||
import { GatewayEventListeners } from "./event-listeners.js";
|
||||
import type { GatewayPendingRequest } from "./pending-request.js";
|
||||
import { clearGatewayConnectTimeout, startGatewayConnectTimeout } from "./timeouts.js";
|
||||
|
||||
export type GatewayProtocolSocket = {
|
||||
isOpen: () => boolean;
|
||||
@@ -146,17 +149,6 @@ type ConnectTimingState = {
|
||||
usedFallback: boolean;
|
||||
};
|
||||
type CloseSnapshot = Omit<GatewayProtocolCloseContext, "code" | "reason">;
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
expectFinal: boolean;
|
||||
acceptedNotified: boolean;
|
||||
onAccepted?: (payload: unknown) => void;
|
||||
cleanup?: () => void;
|
||||
unbounded: boolean;
|
||||
method: string;
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser-safe gateway wire client. Environment adapters own transport and auth
|
||||
@@ -164,8 +156,8 @@ type PendingRequest = {
|
||||
*/
|
||||
export class GatewayProtocolClient<TPlan> {
|
||||
private socket: GatewayProtocolSocket | null = null;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private listeners = new Set<(event: EventFrame) => void>();
|
||||
private readonly pending = new Map<string, GatewayPendingRequest>();
|
||||
private readonly listeners = new GatewayEventListeners<EventFrame>();
|
||||
private stopped = true;
|
||||
private generation = 0;
|
||||
private lastSeq: number | null = null;
|
||||
@@ -250,7 +242,7 @@ export class GatewayProtocolClient<TPlan> {
|
||||
options?.timeoutMs === null ? undefined : (options?.timeoutMs ?? this.opts.requestTimeoutMs);
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const pending: PendingRequest = {
|
||||
const pending: GatewayPendingRequest = {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
expectFinal: options?.expectFinal === true,
|
||||
@@ -312,8 +304,7 @@ export class GatewayProtocolClient<TPlan> {
|
||||
}
|
||||
|
||||
addEventListener(listener: (event: EventFrame) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
return this.listeners.add(listener);
|
||||
}
|
||||
|
||||
closeSocket(code?: number, reason?: string): void {
|
||||
@@ -448,6 +439,13 @@ export class GatewayProtocolClient<TPlan> {
|
||||
}
|
||||
this.connectSent = true;
|
||||
this.clearHandshakeTimer();
|
||||
// The challenge timer ends before asynchronous device preparation. Keep
|
||||
// the same socket supervised until hello so a silent peer cannot strand it.
|
||||
this.handshakeTimer = startGatewayConnectTimeout(() => {
|
||||
if (this.isActive(socket, generation) && !this.helloReceived) {
|
||||
socket.close(4000, "connect timeout");
|
||||
}
|
||||
});
|
||||
let planOrPromise: TPlan | Promise<TPlan>;
|
||||
try {
|
||||
planOrPromise = this.opts.buildConnectPlan({
|
||||
@@ -501,6 +499,7 @@ export class GatewayProtocolClient<TPlan> {
|
||||
return;
|
||||
}
|
||||
this.helloReceived = true;
|
||||
this.clearHandshakeTimer();
|
||||
this.connectFailure = undefined;
|
||||
this.reconnectSupervisor.reset();
|
||||
this.recordTiming("hello", generation, plan);
|
||||
@@ -572,9 +571,17 @@ export class GatewayProtocolClient<TPlan> {
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
// An owner may replace the socket while handling this frame. Snapshot
|
||||
// first so replacement listeners cannot inherit a retired event.
|
||||
const listeners = this.listeners.snapshot();
|
||||
this.invoke("event", () => this.opts.onEvent?.(parsed));
|
||||
for (const listener of this.listeners) {
|
||||
this.invoke("event listener", () => listener(parsed));
|
||||
for (const [listener, subscription] of listeners) {
|
||||
if (!this.isActive(socket, generation)) {
|
||||
return;
|
||||
}
|
||||
if (this.listeners.isCurrent(listener, subscription)) {
|
||||
this.invoke("event listener", () => listener(parsed));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -665,7 +672,7 @@ export class GatewayProtocolClient<TPlan> {
|
||||
|
||||
private finishRequestTiming(
|
||||
id: string,
|
||||
pending: PendingRequest,
|
||||
pending: GatewayPendingRequest,
|
||||
ok: boolean,
|
||||
errorCode?: string,
|
||||
): void {
|
||||
@@ -730,10 +737,7 @@ export class GatewayProtocolClient<TPlan> {
|
||||
}
|
||||
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer);
|
||||
this.handshakeTimer = null;
|
||||
}
|
||||
this.handshakeTimer = clearGatewayConnectTimeout(this.handshakeTimer);
|
||||
}
|
||||
|
||||
private invoke(label: string, callback: () => void): void {
|
||||
|
||||
@@ -28,6 +28,22 @@ function isTestRuntimeEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;
|
||||
/** Default server-side window for gateway preauth handshakes. */
|
||||
export const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Starts the browser-safe deadline that covers Gateway connect preparation and hello. */
|
||||
export function startGatewayConnectTimeout(onTimeout: () => void): ReturnType<typeof setTimeout> {
|
||||
const timer = setTimeout(onTimeout, DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
}
|
||||
|
||||
/** Clears either pending Gateway handshake phase without retaining its timer. */
|
||||
export function clearGatewayConnectTimeout(timer: ReturnType<typeof setTimeout> | null): null {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Default deadline for a single non-streaming Gateway request. */
|
||||
export const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000;
|
||||
/** Minimum client watchdog delay for connect challenge setup. */
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveLocalHeavyCheckEnv,
|
||||
} from "./lib/local-heavy-check-runtime.mjs";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mjs";
|
||||
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
|
||||
import { createSparseTsgoSkipEnv } from "./lib/tsgo-sparse-guard.mjs";
|
||||
|
||||
const NPM_LOCK_POLICY_PATH_RE =
|
||||
@@ -74,6 +75,7 @@ const MACOS_APP_CI_PATH_RE =
|
||||
/^(?:apps\/(?:macos|macos-mlx-tts|shared|swabble)\/|Swabble\/|scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh$|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh$|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts$)/u;
|
||||
let corepackPnpmShimDir;
|
||||
let corepackPnpmShimCleanupRegistered = false;
|
||||
let cachedGeneratedExtensionAssetPaths;
|
||||
let npmLockPackageDirsForChangedPaths;
|
||||
|
||||
async function ensureChangedCheckRuntimeDependencies(paths) {
|
||||
@@ -421,6 +423,11 @@ async function runChangedCheckViaCrabbox(argv = [], env = process.env) {
|
||||
export function createChangedCheckPlan(result, options = {}) {
|
||||
const commands = [];
|
||||
const baseEnv = createChangedCheckChildEnv(options.env ?? process.env);
|
||||
const generatedExtensionAssetPaths = result.paths.some((changedPath) =>
|
||||
LINTABLE_EXTENSION_PATH_RE.test(changedPath),
|
||||
)
|
||||
? (cachedGeneratedExtensionAssetPaths ??= new Set(listGeneratedExtensionAssetSources()))
|
||||
: new Set();
|
||||
const add = (name, args, env) => {
|
||||
if (!commands.some((command) => command.name === name && sameArgs(command.args, args))) {
|
||||
commands.push({ name, args, ...(env ? { env } : {}) });
|
||||
@@ -437,9 +444,18 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
};
|
||||
const addTypecheck = (name, args) => add(name, args, createSparseTsgoSkipEnv(baseEnv));
|
||||
const addLint = (name, args) => add(name, args, baseEnv);
|
||||
const addTargetedLint = (createCommand, lintablePathRe, fallbackName, fallbackArgs) => {
|
||||
const targets = result.paths.filter((changedPath) => lintablePathRe.test(changedPath));
|
||||
const otherPaths = result.paths.filter((changedPath) => !lintablePathRe.test(changedPath));
|
||||
const addTargetedLint = (
|
||||
createCommand,
|
||||
lintablePathRe,
|
||||
fallbackName,
|
||||
fallbackArgs,
|
||||
ignoredPaths,
|
||||
) => {
|
||||
const candidatePaths = ignoredPaths
|
||||
? result.paths.filter((changedPath) => !ignoredPaths.has(changedPath))
|
||||
: result.paths;
|
||||
const targets = candidatePaths.filter((changedPath) => lintablePathRe.test(changedPath));
|
||||
const otherPaths = candidatePaths.filter((changedPath) => !lintablePathRe.test(changedPath));
|
||||
const targetedCommands = [];
|
||||
|
||||
for (let offset = 0; offset < targets.length; offset += TARGETED_LINT_PATH_LIMIT) {
|
||||
@@ -685,12 +701,24 @@ export function createChangedCheckPlan(result, options = {}) {
|
||||
addLint("lint core", ["lint:core"]);
|
||||
}
|
||||
if (lanes.extensions || lanes.extensionTests) {
|
||||
addTargetedLint(
|
||||
createTargetedExtensionLintCommand,
|
||||
LINTABLE_EXTENSION_PATH_RE,
|
||||
"lint extensions",
|
||||
["lint:extensions"],
|
||||
);
|
||||
// Generated plugin outputs have their own asset-integrity gate and are
|
||||
// intentionally ignored by oxlint; manifests still need full-lane fallback.
|
||||
if (
|
||||
!result.paths.some((changedPath) => generatedExtensionAssetPaths.has(changedPath)) ||
|
||||
result.paths.some(
|
||||
(changedPath) =>
|
||||
getChangedPathFacts(changedPath).surface === "extension" &&
|
||||
!generatedExtensionAssetPaths.has(changedPath),
|
||||
)
|
||||
) {
|
||||
addTargetedLint(
|
||||
createTargetedExtensionLintCommand,
|
||||
LINTABLE_EXTENSION_PATH_RE,
|
||||
"lint extensions",
|
||||
["lint:extensions"],
|
||||
generatedExtensionAssetPaths,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (lanes.tooling || lanes.liveDockerTooling) {
|
||||
if (
|
||||
|
||||
@@ -229,7 +229,6 @@ const BLOCKED_WORKSPACE_DOTENV_PREFIXES = [
|
||||
// Workspace .env is untrusted; reserve the full OpenClaw runtime namespace
|
||||
// for shell/global config so new OPENCLAW_* controls are fail-closed by default.
|
||||
"OPENCLAW_",
|
||||
"OPENCLAW_CLAWHUB_",
|
||||
"OPENCLAW_DISABLE_",
|
||||
"OPENCLAW_SKIP_",
|
||||
"OPENCLAW_UPDATE_",
|
||||
|
||||
50
test/scripts/changed-lanes-generated-extension-lint.test.ts
Normal file
50
test/scripts/changed-lanes-generated-extension-lint.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectChangedLanes } from "../../scripts/changed-lanes.mjs";
|
||||
import { createChangedCheckPlan } from "../../scripts/check-changed.mjs";
|
||||
|
||||
describe("generated extension asset lint planning", () => {
|
||||
it("still lints extension tests alongside a generated browser asset", () => {
|
||||
const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js";
|
||||
const extensionTest = "extensions/browser/chrome-extension/modules/copilot-gateway.test.ts";
|
||||
const result = detectChangedLanes([generatedAsset, extensionTest]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
|
||||
expect(result.lanes.extensionTests).toBe(true);
|
||||
expect(plan.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "lint extension changed file",
|
||||
args: [
|
||||
"scripts/run-oxlint.mjs",
|
||||
"--tsconfig",
|
||||
"config/tsconfig/oxlint.extensions.json",
|
||||
extensionTest,
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
plan.commands
|
||||
.filter((command) => command.args[0] === "scripts/run-oxlint.mjs")
|
||||
.flatMap((command) => command.args),
|
||||
).not.toContain(generatedAsset);
|
||||
});
|
||||
|
||||
it("keeps fallback extension lint for a manifest beside a generated browser asset", () => {
|
||||
const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js";
|
||||
const manifest = "extensions/browser/openclaw.plugin.json";
|
||||
const result = detectChangedLanes([generatedAsset, manifest]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
|
||||
expect(result.lanes.extensions).toBe(true);
|
||||
expect(plan.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "lint extensions",
|
||||
args: ["lint:extensions"],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
plan.commands
|
||||
.filter((command) => command.args[0] === "scripts/run-oxlint.mjs")
|
||||
.flatMap((command) => command.args),
|
||||
).not.toContain(generatedAsset);
|
||||
});
|
||||
});
|
||||
@@ -804,6 +804,48 @@ describe("scripts/changed-lanes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps manifest-declared generated browser assets out of targeted extension lint", () => {
|
||||
const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js";
|
||||
const result = detectChangedLanes([
|
||||
generatedAsset,
|
||||
"packages/gateway-client/src/protocol-client.ts",
|
||||
]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
|
||||
expect(result.lanes.extensions).toBe(true);
|
||||
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions");
|
||||
expect(plan.commands.map((command) => command.args[0])).not.toContain("lint:extensions");
|
||||
expect(
|
||||
plan.commands
|
||||
.filter((command) => command.args[0] === "scripts/run-oxlint.mjs")
|
||||
.flatMap((command) => command.args),
|
||||
).not.toContain(generatedAsset);
|
||||
});
|
||||
|
||||
it("still lints extension source alongside its generated browser asset", () => {
|
||||
const generatedAsset = "extensions/browser/chrome-extension/modules/copilot-runtime.js";
|
||||
const source = "extensions/browser/scripts/copilot-runtime-entry.ts";
|
||||
const result = detectChangedLanes([generatedAsset, source]);
|
||||
const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } });
|
||||
|
||||
expect(plan.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "lint extension changed file",
|
||||
args: [
|
||||
"scripts/run-oxlint.mjs",
|
||||
"--tsconfig",
|
||||
"config/tsconfig/oxlint.extensions.json",
|
||||
source,
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
plan.commands
|
||||
.filter((command) => command.args[0] === "scripts/run-oxlint.mjs")
|
||||
.flatMap((command) => command.args),
|
||||
).not.toContain(generatedAsset);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
owner: "core",
|
||||
|
||||
@@ -633,6 +633,117 @@ describe("GatewayBrowserClient", () => {
|
||||
expect(ws.lastClose).toEqual({ code: 4000, reason: "terminal liveness timeout" });
|
||||
});
|
||||
|
||||
it("reconnects a silently stalled socket using its advertised Gateway heartbeat", async () => {
|
||||
useNodeFakeTimers();
|
||||
const client = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL });
|
||||
try {
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
auth: { role: "operator", scopes: [] },
|
||||
policy: { tickIntervalMs: 1_000 },
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
expect(ws.lastClose).toEqual({ code: 4000, reason: "tick timeout" });
|
||||
} finally {
|
||||
client.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([Number.MAX_SAFE_INTEGER, 2 ** 32 + 1])(
|
||||
"clamps the advertised heartbeat %d before scheduling its browser timer",
|
||||
async (advertisedTickIntervalMs) => {
|
||||
useNodeFakeTimers();
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
const client = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL });
|
||||
|
||||
try {
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
auth: { role: "operator", scopes: [] },
|
||||
policy: { tickIntervalMs: advertisedTickIntervalMs },
|
||||
},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 2_147_483_647);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(ws.lastClose).toBeNull();
|
||||
} finally {
|
||||
client.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a healthy heartbeat and explicitly unbounded request alive", async () => {
|
||||
useNodeFakeTimers();
|
||||
const client = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL });
|
||||
try {
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
auth: { role: "operator", scopes: [] },
|
||||
policy: { tickIntervalMs: 1_000 },
|
||||
},
|
||||
});
|
||||
const request = client.request("wizard.next", {}, { timeoutMs: null });
|
||||
const requestFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { id?: string };
|
||||
|
||||
for (let seq = 1; seq <= 4; seq += 1) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
ws.emitMessage({ type: "event", event: "tick", seq, payload: {} });
|
||||
}
|
||||
|
||||
expect(ws.lastClose).toBeNull();
|
||||
ws.emitMessage({ type: "res", id: requestFrame.id, ok: true, payload: { done: true } });
|
||||
await expect(request).resolves.toEqual({ done: true });
|
||||
} finally {
|
||||
client.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("disposes the Gateway heartbeat when its browser client stops", async () => {
|
||||
useNodeFakeTimers();
|
||||
const client = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL });
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 4,
|
||||
auth: { role: "operator", scopes: [] },
|
||||
policy: { tickIntervalMs: 1_000 },
|
||||
},
|
||||
});
|
||||
|
||||
client.stop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(ws.lastClose).toEqual({ code: undefined, reason: undefined });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports failed request timing without including request params", async () => {
|
||||
const onRequestTiming = vi.fn();
|
||||
const client = new GatewayBrowserClient({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
shouldRetryGatewayWithDeviceToken,
|
||||
isRetryableGatewayStartupUnavailableError,
|
||||
resolveGatewayStartupRetryAfterMs,
|
||||
resolveSafeTimeoutDelayMs,
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
} from "@openclaw/gateway-client/browser";
|
||||
@@ -206,6 +207,8 @@ const STARTUP_RETRY_CLOSE_CODE = 4013;
|
||||
const BROWSER_WEBSOCKET_CLOSE_CODE = 1006;
|
||||
const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR";
|
||||
const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR";
|
||||
const DEFAULT_GATEWAY_TICK_INTERVAL_MS = 30_000;
|
||||
const MIN_GATEWAY_TICK_WATCH_INTERVAL_MS = 1_000;
|
||||
function getErrorMessage(err: unknown): string {
|
||||
return err instanceof Error && err.message ? err.message : String(err);
|
||||
}
|
||||
@@ -298,6 +301,8 @@ async function buildGatewayConnectDevice(params: {
|
||||
export class GatewayBrowserClient {
|
||||
private readonly client: GatewayProtocolClient<ConnectPlan>;
|
||||
inboundActivitySeq = 0;
|
||||
private lastInboundActivityAtMs: number | null = null;
|
||||
private tickWatchTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pendingDeviceTokenRetry = false;
|
||||
private deviceTokenRetryBudgetUsed = false;
|
||||
private readonly recoveryScopeTracker = new GatewayRecoveryScopeTracker();
|
||||
@@ -326,6 +331,7 @@ export class GatewayBrowserClient {
|
||||
},
|
||||
resolveClose: (context) => this.resolveClose(context),
|
||||
onClose: (context, decision) => {
|
||||
this.stopTickWatch();
|
||||
const error = context.connectFailure?.error;
|
||||
this.client.recordTiming("failed", context.generation, undefined, {
|
||||
errorCode: error instanceof GatewayRequestError ? error.code : "SOCKET_CLOSED",
|
||||
@@ -342,7 +348,10 @@ export class GatewayBrowserClient {
|
||||
onSocketFactoryError: (error) => this.handleSocketFactoryError(error),
|
||||
onEvent: (event) => this.opts.onEvent?.(event),
|
||||
onGap: (info) => this.opts.onGap?.(info),
|
||||
onActivity: () => (this.inboundActivitySeq += 1),
|
||||
onActivity: () => {
|
||||
this.inboundActivitySeq += 1;
|
||||
this.lastInboundActivityAtMs = Date.now();
|
||||
},
|
||||
onTiming: ({ plan, detail, ...timing }) => {
|
||||
this.opts.onConnectTiming?.({
|
||||
...timing,
|
||||
@@ -374,6 +383,7 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopTickWatch();
|
||||
this.client.stop();
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
@@ -491,6 +501,7 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
|
||||
private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) {
|
||||
this.startTickWatch(hello);
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
this.opts.bootstrapToken = undefined;
|
||||
@@ -506,6 +517,38 @@ export class GatewayBrowserClient {
|
||||
void this.updateRecoveryScopeForHello(hello, plan);
|
||||
}
|
||||
|
||||
private startTickWatch(hello: GatewayHelloOk): void {
|
||||
this.stopTickWatch();
|
||||
const advertisedTickIntervalMs = hello.policy?.tickIntervalMs;
|
||||
// Gateway policy is remote input; use the shared timer clamp so an
|
||||
// oversized interval cannot wrap into a resource-exhausting hot loop.
|
||||
const tickIntervalMs = resolveSafeTimeoutDelayMs(
|
||||
typeof advertisedTickIntervalMs === "number" &&
|
||||
Number.isFinite(advertisedTickIntervalMs) &&
|
||||
advertisedTickIntervalMs > 0
|
||||
? advertisedTickIntervalMs
|
||||
: DEFAULT_GATEWAY_TICK_INTERVAL_MS,
|
||||
{ minMs: MIN_GATEWAY_TICK_WATCH_INTERVAL_MS },
|
||||
);
|
||||
this.lastInboundActivityAtMs = Date.now();
|
||||
this.tickWatchTimer = setInterval(() => {
|
||||
const lastActivityAtMs = this.lastInboundActivityAtMs;
|
||||
// Preserve long-running requests while real Gateway heartbeats arrive;
|
||||
// only a silent socket should enter the shared reconnect lifecycle.
|
||||
if (lastActivityAtMs !== null && Date.now() - lastActivityAtMs > tickIntervalMs * 2) {
|
||||
this.forceReconnect("tick timeout");
|
||||
}
|
||||
}, tickIntervalMs);
|
||||
}
|
||||
|
||||
private stopTickWatch(): void {
|
||||
if (this.tickWatchTimer !== null) {
|
||||
clearInterval(this.tickWatchTimer);
|
||||
this.tickWatchTimer = null;
|
||||
}
|
||||
this.lastInboundActivityAtMs = null;
|
||||
}
|
||||
|
||||
private async updateRecoveryScopeForHello(hello: GatewayHelloOk, plan: ConnectPlan) {
|
||||
if (
|
||||
await this.recoveryScopeTracker.resolve({
|
||||
|
||||
@@ -190,6 +190,14 @@ export class SessionCatalogLiveState {
|
||||
this.progressive = true;
|
||||
}
|
||||
|
||||
retireConnection(reset = false): void {
|
||||
if (reset) {
|
||||
this.resetConnection();
|
||||
return;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
async requestList(
|
||||
client: GatewayBrowserClient,
|
||||
agentId: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import "../test-helpers/app-sidebar-cases/catalog-compat.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-events.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-reconnect.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-ownership.ts";
|
||||
|
||||
@@ -271,6 +271,31 @@ describe("openclaw-exec-approval", () => {
|
||||
expect(onDecision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ reason: "a decision is in flight", busy: true, allowedDecisions: undefined },
|
||||
{ reason: "denial is unavailable", busy: false, allowedDecisions: ["allow-once"] as const },
|
||||
])("keeps the pending approval visible when $reason", async ({ busy, allowedDecisions }) => {
|
||||
const { onDecision } = await renderApproval(
|
||||
createExecRequest({
|
||||
request: {
|
||||
command: "echo hello",
|
||||
...(allowedDecisions ? { allowedDecisions: [...allowedDecisions] } : {}),
|
||||
},
|
||||
}),
|
||||
{ busy },
|
||||
);
|
||||
const { modal } = await getRenderedModalDialog(container);
|
||||
const cancel = new CustomEvent("modal-cancel", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
expect(modal.dispatchEvent(cancel)).toBe(false);
|
||||
expect(cancel.defaultPrevented).toBe(true);
|
||||
expect(onDecision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the automatic modal for the inline request but opens it on demand", async () => {
|
||||
const { approval } = await renderApproval(createExecRequest(), {
|
||||
inlineApprovalId: "approval-1",
|
||||
|
||||
@@ -151,10 +151,13 @@ class ExecApproval extends OpenClawLightDomContentsElement {
|
||||
return nothing;
|
||||
}
|
||||
const decisions = resolveApprovalDecisions(active);
|
||||
const handleCancel = () => {
|
||||
if (!props.busy && decisions.includes("deny")) {
|
||||
void props.onDecision(active.id, "deny");
|
||||
const handleCancel = (event: Event) => {
|
||||
if (props.busy || !decisions.includes("deny")) {
|
||||
// Dismissal must never hide an approval that cannot yet be resolved.
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
void props.onDecision(active.id, "deny");
|
||||
};
|
||||
return html`
|
||||
<openclaw-modal-dialog
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import {
|
||||
refreshSessionCatalogsLive,
|
||||
@@ -69,6 +70,14 @@ export function resolveSessionCatalogAgentId(
|
||||
): string | null {
|
||||
const context = owner.context;
|
||||
const gateway = context?.gateway.snapshot;
|
||||
// Only an authoritative connected hello can revoke catalog ownership; a
|
||||
// transient reconnect still preserves its rows until the replacement lands.
|
||||
if (
|
||||
gateway?.phase === "connected" &&
|
||||
isGatewayMethodAdvertised(gateway, "sessions.catalog.list") === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const agentsState = context?.agents.state;
|
||||
const agentsList =
|
||||
gateway?.phase === "connected" &&
|
||||
|
||||
@@ -254,13 +254,13 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
globalThis.removeEventListener("focus", this.handleSessionCatalogPageActivation);
|
||||
}
|
||||
|
||||
retireSessionCatalogData(): void {
|
||||
retireSessionCatalogData(resetConnection = false): void {
|
||||
this.sessionScopeGeneration += 1;
|
||||
this.sidebarSessionPaginationState.listRequestToken = null;
|
||||
this.sidebarSessionPaginationState.pageRequestToken = null;
|
||||
this.sessionsLoading = false;
|
||||
this.loadingMoreSessionCatalogIds = new Set();
|
||||
this.sessionCatalogLive.clear();
|
||||
this.sessionCatalogLive.retireConnection(resetConnection);
|
||||
}
|
||||
|
||||
resetSessionCatalogConnection(): void {
|
||||
@@ -509,7 +509,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
}
|
||||
this.notify();
|
||||
if (!sourceOrClientChanged) {
|
||||
this.retireSessionCatalogData();
|
||||
this.retireSessionCatalogData(!connected);
|
||||
if (connected && this.sessionsSource && this.host.sidebarSessionStatusFilter() !== "active") {
|
||||
void this.refreshSidebarSessions();
|
||||
}
|
||||
|
||||
@@ -601,6 +601,13 @@ suite.define(() => {
|
||||
),
|
||||
)
|
||||
.toBe(64);
|
||||
const initialBlobUrls = await page
|
||||
.locator("img.chat-message-image")
|
||||
.evaluateAll((images) => images.map((image) => image.getAttribute("src")));
|
||||
const retainedRecentBlobUrl = expectDefined(
|
||||
initialBlobUrls[0],
|
||||
"recent managed image Blob URL",
|
||||
);
|
||||
|
||||
await replaceHistory(
|
||||
historyFor([0], "Recently viewed managed image"),
|
||||
@@ -611,26 +618,30 @@ suite.define(() => {
|
||||
await replaceHistory(historyFor([64], "Overflow managed image"), "Overflow managed image 65");
|
||||
await expect.poll(async () => (await readBlobProof()).created.length).toBe(65);
|
||||
const overflowProof = await readBlobProof();
|
||||
const retainedRecentBlobUrl = expectDefined(
|
||||
overflowProof.created[0],
|
||||
"recent managed image Blob URL",
|
||||
);
|
||||
// Concurrent image fetches can resolve in any order. Find the real LRU
|
||||
// rather than assuming that creation order matches transcript order.
|
||||
const evictedBlobUrl = expectDefined(
|
||||
overflowProof.created[1],
|
||||
overflowProof.created.find((blobUrl) => blobUrl !== retainedRecentBlobUrl),
|
||||
"evicted managed image Blob URL",
|
||||
);
|
||||
const evictedImageIndex = initialBlobUrls.indexOf(evictedBlobUrl);
|
||||
expect(evictedImageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(overflowProof.revoked).toContain(evictedBlobUrl);
|
||||
expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl);
|
||||
|
||||
const evictedPath = new URL(
|
||||
expectDefined(imageUrls[1], "evicted managed image URL"),
|
||||
expectDefined(imageUrls[evictedImageIndex], "evicted managed image URL"),
|
||||
suite.server.baseUrl,
|
||||
).pathname;
|
||||
const fetchesBeforeRevisit = fetchedMedia.filter(
|
||||
(request) => request.pathname === evictedPath,
|
||||
).length;
|
||||
await replaceHistory(historyFor([1], "Refetched managed image"), "Refetched managed image 2");
|
||||
const revisitedImage = page.getByAltText("Refetched managed image 2");
|
||||
const revisitedImageAlt = `Refetched managed image ${evictedImageIndex + 1}`;
|
||||
await replaceHistory(
|
||||
historyFor([evictedImageIndex], "Refetched managed image"),
|
||||
revisitedImageAlt,
|
||||
);
|
||||
const revisitedImage = page.getByAltText(revisitedImageAlt);
|
||||
await expect
|
||||
.poll(() =>
|
||||
revisitedImage.evaluate((image) =>
|
||||
@@ -655,7 +666,7 @@ suite.define(() => {
|
||||
const proofSummary = {
|
||||
cacheCapacity: 64,
|
||||
createdBlobUrls: finalProof.created.length,
|
||||
evictedBlobIndex: 1,
|
||||
evictedBlobIndex: evictedImageIndex,
|
||||
evictedImageFetches,
|
||||
refetchedImageNaturalWidth: await revisitedImage.evaluate(
|
||||
(image) => (image as HTMLImageElement).naturalWidth,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import type { EventFrame } from "@openclaw/gateway-protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayBoardProvider, type BoardProvider } from "./provider.ts";
|
||||
|
||||
@@ -154,6 +155,168 @@ describe("gateway board provider lifecycle", () => {
|
||||
expect(provider.snapshot$.value).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("pauses board retries during an outage and refreshes once after reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
let connected = true;
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:paused-reconnect",
|
||||
revision: 1,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporarily unavailable"))
|
||||
.mockImplementation(async () => {
|
||||
if (!connected) {
|
||||
throw new Error("gateway not connected");
|
||||
}
|
||||
return snapshot;
|
||||
});
|
||||
const client = {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const provider = new GatewayBoardProvider(snapshot.sessionKey, client);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
|
||||
connected = false;
|
||||
provider.attachClient(client, false);
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
connected = true;
|
||||
provider.attachClient(client, true);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(provider.snapshot$.value).toEqual(snapshot);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it("pauses a failed user-requested widget refresh until reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:offline-widget-refresh",
|
||||
revision: 1,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("gateway not connected"))
|
||||
.mockResolvedValue(snapshot);
|
||||
const client = {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const provider = new GatewayBoardProvider(snapshot.sessionKey, client, false);
|
||||
|
||||
const refresh = provider.refreshWidgetFrame("status");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await refresh;
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
provider.attachClient(client, true);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(provider.snapshot$.value).toEqual(snapshot);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it("preserves a manual offline refresh that joins an existing retry loop", async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:coalesced-offline-widget-refresh",
|
||||
revision: 1,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporarily unavailable"))
|
||||
.mockRejectedValueOnce(new Error("gateway not connected"))
|
||||
.mockResolvedValue(snapshot);
|
||||
const client = {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
};
|
||||
const provider = new GatewayBoardProvider(snapshot.sessionKey, client);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
|
||||
provider.attachClient(client, false);
|
||||
const refresh = provider.refreshWidgetFrame("status");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await refresh;
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
provider.attachClient(client, true);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(provider.snapshot$.value).toEqual(snapshot);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it("does not reread a queued manual refresh after its gateway disconnects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const initial = {
|
||||
sessionKey: "agent:main:offline-queued-refresh",
|
||||
revision: 1,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
};
|
||||
const changed = { ...initial, revision: 2 };
|
||||
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
|
||||
const resolvers: Array<(value: typeof initial) => void> = [];
|
||||
const request = vi.fn(
|
||||
() =>
|
||||
new Promise<typeof initial>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
const client = {
|
||||
request: request as never,
|
||||
addEventListener: (next: (event: EventFrame) => void) => {
|
||||
listener = next as typeof listener;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const provider = new GatewayBoardProvider(initial.sessionKey, client, true);
|
||||
const refresh = provider.refreshWidgetFrame("status");
|
||||
|
||||
listener?.({
|
||||
event: "board.changed",
|
||||
payload: { sessionKey: initial.sessionKey, revision: changed.revision },
|
||||
});
|
||||
provider.attachClient(client, false);
|
||||
resolvers[0]?.(initial);
|
||||
await refresh;
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
provider.attachClient(client, true);
|
||||
resolvers[1]?.(changed);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(provider.snapshot$.value).toEqual(changed);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it("retries a transient board.changed refresh failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
|
||||
@@ -240,17 +403,15 @@ describe("gateway board provider lifecycle", () => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
const provider = new GatewayBoardProvider(
|
||||
initial.sessionKey,
|
||||
{
|
||||
request: request as never,
|
||||
addEventListener: (next) => {
|
||||
listener = next as typeof listener;
|
||||
return () => {};
|
||||
},
|
||||
const client = {
|
||||
request: request as never,
|
||||
addEventListener: (next: (event: EventFrame) => void) => {
|
||||
listener = next as typeof listener;
|
||||
return () => {};
|
||||
},
|
||||
false,
|
||||
);
|
||||
};
|
||||
const provider = new GatewayBoardProvider(initial.sessionKey, client, false);
|
||||
provider.attachClient(client, true);
|
||||
|
||||
const refresh = provider.refreshWidgetFrame("status");
|
||||
listener?.({
|
||||
|
||||
@@ -37,6 +37,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
private refreshLoop: Promise<void> | undefined;
|
||||
private refreshRequested = false;
|
||||
private userRefreshRequested = false;
|
||||
private readonly changedWidgets = new Set<string>();
|
||||
private stateGeneration = 0;
|
||||
private connected = false;
|
||||
@@ -71,6 +72,9 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
}
|
||||
const connectionActivated = connected && !this.connected;
|
||||
this.connected = connected;
|
||||
if (!connected) {
|
||||
this.wakeRetryDelay?.();
|
||||
}
|
||||
if (client === this.client) {
|
||||
if (connectionActivated) {
|
||||
void this.activate();
|
||||
@@ -112,6 +116,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
this.clientGeneration += 1;
|
||||
this.stateGeneration += 1;
|
||||
this.refreshRequested = false;
|
||||
this.userRefreshRequested = false;
|
||||
this.changedWidgets.clear();
|
||||
this.appViews.clear();
|
||||
this.wakeRetryDelay?.();
|
||||
@@ -189,7 +194,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
}
|
||||
|
||||
refreshWidgetFrame(name: string): Promise<void> {
|
||||
return this.requestRefresh(name);
|
||||
return this.requestRefresh(name, true);
|
||||
}
|
||||
|
||||
async widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
|
||||
@@ -258,18 +263,19 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
);
|
||||
}
|
||||
|
||||
private requestRefresh(changedWidget?: string): Promise<void> {
|
||||
private requestRefresh(changedWidget?: string, userRequested = false): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.refreshRequested = true;
|
||||
this.userRefreshRequested ||= userRequested;
|
||||
if (changedWidget) {
|
||||
this.changedWidgets.add(changedWidget);
|
||||
}
|
||||
this.wakeRetryDelay?.();
|
||||
this.refreshLoop ??= this.runRefreshLoop().finally(() => {
|
||||
this.refreshLoop = undefined;
|
||||
if (this.refreshRequested) {
|
||||
if (this.refreshRequested && (this.connected || this.userRefreshRequested)) {
|
||||
void this.requestRefresh();
|
||||
}
|
||||
});
|
||||
@@ -283,6 +289,14 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
this.refreshRequested = false;
|
||||
return;
|
||||
}
|
||||
// Preserve pending board changes without polling a disconnected client;
|
||||
// the next attached live connection owns the replacement refresh.
|
||||
if (!this.connected && !this.userRefreshRequested) {
|
||||
return;
|
||||
}
|
||||
// A manual refresh permits one offline request, never a follow-up after
|
||||
// that request completes and discovers a disconnected Gateway.
|
||||
this.userRefreshRequested = false;
|
||||
const changedWidgets = new Set(this.changedWidgets);
|
||||
this.changedWidgets.clear();
|
||||
const client = this.client;
|
||||
@@ -298,6 +312,9 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
this.refreshRequested = true;
|
||||
continue;
|
||||
}
|
||||
// A completed request satisfies any manual refresh that joined it;
|
||||
// only a newer board change should require a follow-up request.
|
||||
this.userRefreshRequested = false;
|
||||
if (stateGeneration !== this.stateGeneration) {
|
||||
this.refreshRequested = true;
|
||||
for (const name of changedWidgets) {
|
||||
@@ -325,6 +342,12 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
for (const name of changedWidgets) {
|
||||
this.changedWidgets.add(name);
|
||||
}
|
||||
if (!this.connected) {
|
||||
if (this.userRefreshRequested) {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const delayMs = retry.delayMs;
|
||||
// Carry backoff across failed loop iterations; successful refreshes reset it above.
|
||||
retry.delayMs = Math.min(delayMs * 2, 30_000);
|
||||
|
||||
114
ui/src/pages/chat/chat-history-subscription-disposal.test.ts
Normal file
114
ui/src/pages/chat/chat-history-subscription-disposal.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import {
|
||||
disposeSelectedSessionMessageSubscription,
|
||||
syncSelectedSessionMessageSubscription,
|
||||
type ChatState,
|
||||
} from "./chat-history.ts";
|
||||
|
||||
const subscription = { key: "agent:main:main", agentId: null };
|
||||
|
||||
function createSubscriptionState(
|
||||
unsubscribeMessages: ReturnType<typeof vi.fn<SessionCapability["unsubscribeMessages"]>>,
|
||||
subscribeMessages: ReturnType<typeof vi.fn<SessionCapability["subscribeMessages"]>> = vi.fn<
|
||||
SessionCapability["subscribeMessages"]
|
||||
>(),
|
||||
): ChatState {
|
||||
return {
|
||||
client: {} as GatewayBrowserClient,
|
||||
connected: true,
|
||||
connectionEpoch: 1,
|
||||
sessionKey: subscription.key,
|
||||
chatLoading: false,
|
||||
chatMessages: [],
|
||||
chatThinkingLevel: null,
|
||||
chatVerboseLevel: null,
|
||||
chatSending: false,
|
||||
chatMessage: "",
|
||||
chatAttachments: [],
|
||||
chatQueue: [],
|
||||
chatRunId: null,
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
lastError: null,
|
||||
hello: null,
|
||||
sessions: { subscribeMessages, unsubscribeMessages },
|
||||
};
|
||||
}
|
||||
|
||||
describe("disposed chat message subscriptions", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("releases an active message subscription when its pane is disposed", () => {
|
||||
const unsubscribeMessages = vi
|
||||
.fn<SessionCapability["unsubscribeMessages"]>()
|
||||
.mockResolvedValue(undefined);
|
||||
const state = createSubscriptionState(unsubscribeMessages);
|
||||
state.chatSessionMessageSubscriptionRequestedKey = subscription.key;
|
||||
state.chatSessionMessageSubscription = subscription;
|
||||
|
||||
disposeSelectedSessionMessageSubscription(state);
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledExactlyOnceWith(subscription);
|
||||
expect(state.chatSessionMessageSubscriptionRequestedKey).toBeNull();
|
||||
expect(state.chatSessionMessageSubscription).toBeNull();
|
||||
});
|
||||
|
||||
it("releases a subscription that resolves after its pane is disposed", async () => {
|
||||
let resolveSubscription: (value: typeof subscription) => void = () => undefined;
|
||||
const pendingSubscription = new Promise<typeof subscription>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
const unsubscribeMessages = vi
|
||||
.fn<SessionCapability["unsubscribeMessages"]>()
|
||||
.mockResolvedValue(undefined);
|
||||
const state = createSubscriptionState(
|
||||
unsubscribeMessages,
|
||||
vi.fn<SessionCapability["subscribeMessages"]>().mockReturnValue(pendingSubscription),
|
||||
);
|
||||
|
||||
const sync = syncSelectedSessionMessageSubscription(state as never);
|
||||
await Promise.resolve();
|
||||
disposeSelectedSessionMessageSubscription(state);
|
||||
resolveSubscription(subscription);
|
||||
await sync;
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledExactlyOnceWith(subscription);
|
||||
expect(state.chatSessionMessageSubscription).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a temporary release failure without another pane synchronization", async () => {
|
||||
vi.useFakeTimers();
|
||||
const unsubscribeMessages = vi
|
||||
.fn<SessionCapability["unsubscribeMessages"]>()
|
||||
.mockRejectedValueOnce(new Error("temporary observer release failure"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const state = createSubscriptionState(unsubscribeMessages);
|
||||
state.chatSessionMessageSubscription = subscription;
|
||||
|
||||
disposeSelectedSessionMessageSubscription(state);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledTimes(2);
|
||||
expect(unsubscribeMessages).toHaveBeenLastCalledWith(subscription);
|
||||
expect(state.chatSessionMessageSubscription).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds permanently failing releases without leaking retry timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const unsubscribeMessages = vi
|
||||
.fn<SessionCapability["unsubscribeMessages"]>()
|
||||
.mockRejectedValue(new Error("observer unavailable"));
|
||||
const state = createSubscriptionState(unsubscribeMessages);
|
||||
state.chatSessionMessageSubscription = subscription;
|
||||
|
||||
disposeSelectedSessionMessageSubscription(state);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(unsubscribeMessages).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(state.chatSessionMessageSubscription).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,8 @@ const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
|
||||
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
|
||||
const CHAT_HISTORY_REQUEST_LIMIT = 100;
|
||||
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
|
||||
const SESSION_MESSAGE_RELEASE_RETRY_MS = 250;
|
||||
const MAX_SESSION_MESSAGE_RELEASE_ATTEMPTS = 3;
|
||||
|
||||
type ChatHistoryPaneRequests = {
|
||||
historyVersion: number;
|
||||
@@ -298,6 +300,8 @@ export type ChatState = {
|
||||
canvasPluginSurfaceUrl?: string | null;
|
||||
settings?: { chatPersistCommentary?: boolean; gatewayUrl?: string | null };
|
||||
sessions?: Partial<SessionCapability>;
|
||||
chatSessionMessageSubscriptionRequestedKey?: string | null;
|
||||
chatSessionMessageSubscription?: SessionMessageSubscription | null;
|
||||
chatBranches?: SessionBranch[];
|
||||
chatBranchesSessionKey?: string | null;
|
||||
chatBranchesConnectionEpoch?: number | null;
|
||||
@@ -714,6 +718,44 @@ async function retryPendingSessionMessageSubscriptionReleases(
|
||||
);
|
||||
}
|
||||
|
||||
export function disposeSelectedSessionMessageSubscription(state: ChatState): void {
|
||||
const requests = getChatHistoryPaneRequests(state);
|
||||
requests.subscriptionGeneration += 1;
|
||||
const subscriptions = new Set(requests.pendingSubscriptionReleases);
|
||||
requests.pendingSubscriptionReleases.clear();
|
||||
if (state.chatSessionMessageSubscription) {
|
||||
subscriptions.add(state.chatSessionMessageSubscription);
|
||||
}
|
||||
state.chatSessionMessageSubscriptionRequestedKey = null;
|
||||
state.chatSessionMessageSubscription = null;
|
||||
const sessions = state.sessions;
|
||||
if (!sessions?.unsubscribeMessages) {
|
||||
return;
|
||||
}
|
||||
const unsubscribeMessages = sessions.unsubscribeMessages.bind(sessions);
|
||||
for (const subscription of subscriptions) {
|
||||
// A detached pane cannot drain another queue. Retry on its longer-lived
|
||||
// session owner, but stop after terminal failures so timers cannot leak.
|
||||
void (async () => {
|
||||
let retryDelayMs = SESSION_MESSAGE_RELEASE_RETRY_MS;
|
||||
for (let attempt = 0; attempt < MAX_SESSION_MESSAGE_RELEASE_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
await unsubscribeMessages(subscription);
|
||||
return;
|
||||
} catch {
|
||||
if (attempt + 1 === MAX_SESSION_MESSAGE_RELEASE_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
globalThis.setTimeout(resolve, retryDelayMs);
|
||||
});
|
||||
retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSelectedSessionMessageSubscription(
|
||||
state: ChatSessionMessageSubscriptionState,
|
||||
opts?: { force?: boolean },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
||||
import { disposeSelectedSessionMessageSubscription } from "./chat-history.ts";
|
||||
import { subscribeChatOutboxProjection } from "./chat-queue.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { invalidateImageLightbox } from "./chat-state-page.ts";
|
||||
@@ -75,6 +76,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
||||
|
||||
attach(state: TState) {
|
||||
if (this.stateValue && this.stateValue !== state) {
|
||||
disposeSelectedSessionMessageSubscription(this.stateValue);
|
||||
releaseChatMediaResourceSubscriber(this.stateValue.requestUpdate);
|
||||
this.attachmentReads.abortReads();
|
||||
this.composerPersistence.stop();
|
||||
@@ -360,6 +362,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
||||
}
|
||||
const state = this.stateValue;
|
||||
if (state) {
|
||||
disposeSelectedSessionMessageSubscription(state);
|
||||
releaseChatMediaResourceSubscriber(state.requestUpdate);
|
||||
cancelChatStreamRenderFrame(state);
|
||||
cancelChatScroll(state);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
observeChatMediaResource,
|
||||
readManagedImageBlobUrl,
|
||||
releaseChatMediaResourceSubscriber,
|
||||
schedulePairingQrExpiryRefresh,
|
||||
type ImageRenderOptions,
|
||||
type RenderableImageBlock,
|
||||
} from "./chat-message-media.ts";
|
||||
@@ -77,6 +78,50 @@ function observeSubscriber(subscriber: () => void): () => void {
|
||||
}
|
||||
|
||||
describe("chat media resource lifecycle", () => {
|
||||
it("refreshes every split pane when a shared pairing QR expires", async () => {
|
||||
const message = {
|
||||
content: [
|
||||
{
|
||||
type: "openclaw_pairing_qr",
|
||||
image_url: "data:image/png;base64,cXJwbmc=",
|
||||
expiresAtMs: Date.now() + 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
const refreshFirst = observeSubscriber(vi.fn());
|
||||
const refreshSecond = observeSubscriber(vi.fn());
|
||||
|
||||
schedulePairingQrExpiryRefresh("shared-pairing-qr", message, refreshFirst);
|
||||
schedulePairingQrExpiryRefresh("shared-pairing-qr", message, refreshSecond);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(refreshFirst).toHaveBeenCalledOnce();
|
||||
expect(refreshSecond).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases a pairing QR expiry timer when its chat pane disconnects", async () => {
|
||||
const refresh = observeSubscriber(vi.fn());
|
||||
schedulePairingQrExpiryRefresh(
|
||||
"disconnected-pairing-qr",
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: "openclaw_pairing_qr",
|
||||
image_url: "data:image/png;base64,cXJwbmc=",
|
||||
expiresAtMs: Date.now() + 1_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
refresh,
|
||||
);
|
||||
|
||||
releaseChatMediaResourceSubscriber(refresh);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("wakes a managed image after one transient failure without an external render", async () => {
|
||||
const source = managedImageSource();
|
||||
const blobUrl = installManagedImageUrls();
|
||||
|
||||
@@ -11,12 +11,6 @@ export type PairingQrExpiryNotice = {
|
||||
title: string;
|
||||
reason: string;
|
||||
};
|
||||
type PairingQrExpiryRefreshTimer = {
|
||||
expiresAtMs: number;
|
||||
onRequestUpdate: () => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const pairingQrExpiryRefreshTimers = new Map<string, PairingQrExpiryRefreshTimer>();
|
||||
|
||||
export type ImageBlock = {
|
||||
url: string;
|
||||
@@ -48,7 +42,7 @@ export type RenderableImageBlock = ImageBlock & {
|
||||
|
||||
export type AttachmentItem = Extract<MessageContentItem, { type: "attachment" }>;
|
||||
|
||||
type ChatMediaResourceKind = "assistant-attachment" | "managed-image";
|
||||
type ChatMediaResourceKind = "assistant-attachment" | "managed-image" | "pairing-qr";
|
||||
|
||||
export type ChatMediaResource<Value> = {
|
||||
kind: ChatMediaResourceKind;
|
||||
@@ -530,41 +524,30 @@ function resolveNearestFuturePairingQrExpiresAtMs(
|
||||
return nearestExpiresAtMs;
|
||||
}
|
||||
|
||||
function clearPairingQrExpiryRefreshTimer(messageKey: string) {
|
||||
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(existing.timer);
|
||||
pairingQrExpiryRefreshTimers.delete(messageKey);
|
||||
}
|
||||
|
||||
export function schedulePairingQrExpiryRefresh(
|
||||
messageKey: string,
|
||||
message: unknown,
|
||||
onRequestUpdate: (() => void) | undefined,
|
||||
) {
|
||||
const nowMs = Date.now();
|
||||
const expiresAtMs = resolveNearestFuturePairingQrExpiresAtMs(message, nowMs);
|
||||
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
|
||||
if (!expiresAtMs || !onRequestUpdate) {
|
||||
if (existing) {
|
||||
clearPairingQrExpiryRefreshTimer(messageKey);
|
||||
if (!onRequestUpdate) {
|
||||
return;
|
||||
}
|
||||
const refreshAt = resolveNearestFuturePairingQrExpiresAtMs(message);
|
||||
if (refreshAt === undefined) {
|
||||
const subscriber = chatMediaSubscribers.get(onRequestUpdate);
|
||||
const resourceKey = chatMediaResourceKey("pairing-qr", messageKey);
|
||||
const resource = subscriber?.resources.get(resourceKey);
|
||||
if (subscriber && resource) {
|
||||
subscriber.resources.delete(resourceKey);
|
||||
detachChatMediaResourceSubscriber(resource, onRequestUpdate);
|
||||
pruneChatMediaSubscriber(onRequestUpdate, subscriber);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (existing?.expiresAtMs === expiresAtMs && existing.onRequestUpdate === onRequestUpdate) {
|
||||
return;
|
||||
}
|
||||
clearPairingQrExpiryRefreshTimer(messageKey);
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
pairingQrExpiryRefreshTimers.delete(messageKey);
|
||||
onRequestUpdate();
|
||||
},
|
||||
Math.max(0, expiresAtMs - nowMs),
|
||||
const resource = observeChatMediaResource<void>("pairing-qr", messageKey, onRequestUpdate);
|
||||
scheduleChatMediaResourceRefresh(resource, refreshAt, () =>
|
||||
notifyChatMediaResourceSubscribers(resource),
|
||||
);
|
||||
pairingQrExpiryRefreshTimers.set(messageKey, { expiresAtMs, onRequestUpdate, timer });
|
||||
}
|
||||
|
||||
export function extractTranscriptAttachments(message: unknown): AttachmentItem[] {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isChatMediaResourceCurrent,
|
||||
observeChatMediaResource,
|
||||
releaseChatMediaResourceSubscriber,
|
||||
schedulePairingQrExpiryRefresh,
|
||||
} from "./chat-message-media.ts";
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const subscriber of subscribers) {
|
||||
releaseChatMediaResourceSubscriber(subscriber);
|
||||
}
|
||||
subscribers.clear();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function observeSubscriber(subscriber: () => void): () => void {
|
||||
subscribers.add(subscriber);
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
function pairingQrMessage(expiresAtMs: number): { content: Record<string, unknown>[] } {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "openclaw_pairing_qr",
|
||||
image_url: "data:image/png;base64,cXJwbmc=",
|
||||
expiresAtMs,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("pairing QR expiry resource lifecycle", () => {
|
||||
it.each([
|
||||
{ name: "ordinary messages", message: { content: [{ type: "text", text: "hello" }] } },
|
||||
{
|
||||
name: "expired pairing QR messages",
|
||||
message: () => pairingQrMessage(Date.now() - 1),
|
||||
},
|
||||
])("does not subscribe or schedule expiry for $name", ({ message }) => {
|
||||
const messageKey = crypto.randomUUID();
|
||||
const refresh = observeSubscriber(vi.fn());
|
||||
const resolvedMessage = typeof message === "function" ? message() : message;
|
||||
|
||||
schedulePairingQrExpiryRefresh(messageKey, resolvedMessage, refresh);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(observeChatMediaResource<void>("pairing-qr", messageKey).subscribers.size).toBe(0);
|
||||
|
||||
// Reattach the probe so normal subscriber cleanup also removes its resource.
|
||||
schedulePairingQrExpiryRefresh(messageKey, pairingQrMessage(Date.now() + 1_000), refresh);
|
||||
});
|
||||
|
||||
it("removes the last subscription when a previously active QR loses its expiry", async () => {
|
||||
const messageKey = crypto.randomUUID();
|
||||
const refresh = observeSubscriber(vi.fn());
|
||||
|
||||
schedulePairingQrExpiryRefresh(messageKey, pairingQrMessage(Date.now() + 1_000), refresh);
|
||||
const resource = observeChatMediaResource<void>("pairing-qr", messageKey);
|
||||
|
||||
schedulePairingQrExpiryRefresh(messageKey, { content: [] }, refresh);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(isChatMediaResourceCurrent(resource)).toBe(false);
|
||||
expect(resource.subscribers.size).toBe(0);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the other split pane and unrelated media subscriptions alive", async () => {
|
||||
const messageKey = crypto.randomUUID();
|
||||
const first = observeSubscriber(vi.fn());
|
||||
const second = observeSubscriber(vi.fn());
|
||||
const message = pairingQrMessage(Date.now() + 1_000);
|
||||
const independentResource = observeChatMediaResource<void>(
|
||||
"assistant-attachment",
|
||||
crypto.randomUUID(),
|
||||
first,
|
||||
);
|
||||
|
||||
schedulePairingQrExpiryRefresh(messageKey, message, first);
|
||||
schedulePairingQrExpiryRefresh(messageKey, message, second);
|
||||
schedulePairingQrExpiryRefresh(messageKey, { content: [] }, first);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(independentResource.subscribers.has(first)).toBe(true);
|
||||
expect(isChatMediaResourceCurrent(independentResource)).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -5,74 +5,86 @@ import { catalogPage, createGatewayHarness, createSessions, mountSidebar } from
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar session catalog ownership", () => {
|
||||
it("retires catalog rows and creation after reconnect loses its catalog owner", async () => {
|
||||
vi.useFakeTimers();
|
||||
let provider: HTMLElement | undefined;
|
||||
try {
|
||||
const firstPage = catalogPage([{ threadId: "thread-1", name: "Retired session" }], "page-2");
|
||||
const catalog = firstPage.catalogs[0];
|
||||
if (!catalog) {
|
||||
throw new Error("expected a session catalog");
|
||||
it.each([
|
||||
{ owner: "the selected agent", assistantAgentId: null },
|
||||
{ owner: "the advertised catalog capability", assistantAgentId: "main" },
|
||||
])(
|
||||
"retires catalog rows and creation after reconnect loses $owner",
|
||||
async ({ assistantAgentId }) => {
|
||||
vi.useFakeTimers();
|
||||
let provider: HTMLElement | undefined;
|
||||
try {
|
||||
const firstPage = catalogPage(
|
||||
[{ threadId: "thread-1", name: "Retired session" }],
|
||||
"page-2",
|
||||
);
|
||||
const catalog = firstPage.catalogs[0];
|
||||
if (!catalog) {
|
||||
throw new Error("expected a session catalog");
|
||||
}
|
||||
catalog.capabilities.createSession = { model: "anthropic/claude-opus-4-8" };
|
||||
const expandedPage = catalogPage([{ threadId: "thread-2", name: "Retired page" }]);
|
||||
const expandedCatalog = expandedPage.catalogs[0];
|
||||
if (!expandedCatalog) {
|
||||
throw new Error("expected an expanded session catalog");
|
||||
}
|
||||
expandedCatalog.capabilities.createSession = catalog.capabilities.createSession;
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(expandedPage);
|
||||
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
const catalogHello = {
|
||||
type: "hello-ok",
|
||||
protocol: 1,
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
features: { methods: ["sessions.catalog.list"] },
|
||||
} satisfies NonNullable<ApplicationGatewaySnapshot["hello"]>;
|
||||
gateway.publish({ hello: catalogHello });
|
||||
const mounted = await mountSidebar(
|
||||
gateway.gateway,
|
||||
createSessions("main", ["agent:main:main"]),
|
||||
);
|
||||
const { sidebar } = mounted;
|
||||
provider = mounted.provider;
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await sidebar.sessionData.loadMoreSessionCatalog("codex");
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.textContent).toContain("Retired session");
|
||||
expect(sidebar.textContent).toContain("Retired page");
|
||||
expect(sidebar.querySelector(".sidebar-session-catalog-new")).not.toBeNull();
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
|
||||
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(1);
|
||||
|
||||
gateway.publish({ phase: "reconnecting", hello: null });
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.textContent).toContain("Retired page");
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
|
||||
|
||||
gateway.publish({
|
||||
phase: "connected",
|
||||
assistantAgentId,
|
||||
hello: { ...catalogHello, features: { ...catalogHello.features, methods: [] } },
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.sessionData.sessionCatalogAgentId).toBeNull();
|
||||
expect(sidebar.sessionData.sessionCatalogs).toEqual([]);
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(0);
|
||||
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(0);
|
||||
expect(sidebar.textContent).not.toContain("Retired session");
|
||||
expect(sidebar.textContent).not.toContain("Retired page");
|
||||
expect(sidebar.querySelector(".sidebar-session-catalog-new")).toBeNull();
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
provider?.remove();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
catalog.capabilities.createSession = { model: "anthropic/claude-opus-4-8" };
|
||||
const expandedPage = catalogPage([{ threadId: "thread-2", name: "Retired page" }]);
|
||||
const expandedCatalog = expandedPage.catalogs[0];
|
||||
if (!expandedCatalog) {
|
||||
throw new Error("expected an expanded session catalog");
|
||||
}
|
||||
expandedCatalog.capabilities.createSession = catalog.capabilities.createSession;
|
||||
const request = vi.fn().mockResolvedValueOnce(firstPage).mockResolvedValueOnce(expandedPage);
|
||||
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
const catalogHello = {
|
||||
type: "hello-ok",
|
||||
protocol: 1,
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
features: { methods: ["sessions.catalog.list"] },
|
||||
} satisfies NonNullable<ApplicationGatewaySnapshot["hello"]>;
|
||||
gateway.publish({ hello: catalogHello });
|
||||
const mounted = await mountSidebar(
|
||||
gateway.gateway,
|
||||
createSessions("main", ["agent:main:main"]),
|
||||
);
|
||||
const { sidebar } = mounted;
|
||||
provider = mounted.provider;
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await sidebar.sessionData.loadMoreSessionCatalog("codex");
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.textContent).toContain("Retired session");
|
||||
expect(sidebar.textContent).toContain("Retired page");
|
||||
expect(sidebar.querySelector(".sidebar-session-catalog-new")).not.toBeNull();
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
|
||||
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(1);
|
||||
|
||||
gateway.publish({ phase: "reconnecting", hello: null });
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.textContent).toContain("Retired page");
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
|
||||
|
||||
gateway.publish({
|
||||
phase: "connected",
|
||||
assistantAgentId: null,
|
||||
hello: { ...catalogHello, features: { ...catalogHello.features, methods: [] } },
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.sessionData.sessionCatalogAgentId).toBeNull();
|
||||
expect(sidebar.sessionData.sessionCatalogs).toEqual([]);
|
||||
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(0);
|
||||
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(0);
|
||||
expect(sidebar.textContent).not.toContain("Retired session");
|
||||
expect(sidebar.textContent).not.toContain("Retired page");
|
||||
expect(sidebar.querySelector(".sidebar-session-catalog-new")).toBeNull();
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
provider?.remove();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
62
ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts
Normal file
62
ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionsCatalogListResult } from "../../../../packages/gateway-protocol/src/index.ts";
|
||||
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import {
|
||||
catalogPage,
|
||||
createGatewayHarness,
|
||||
createSessions,
|
||||
deferred,
|
||||
mountSidebar,
|
||||
} from "../app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar catalog reconnect", () => {
|
||||
it("keeps progressive catalogs after a stale fallback and same-client reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const legacyFallback = deferred<SessionsCatalogListResult>();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message:
|
||||
"invalid sessions.catalog.list params: at root: unexpected property 'progressId'",
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(legacyFallback.promise)
|
||||
.mockResolvedValue(catalogPage([]));
|
||||
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
const hello = {
|
||||
features: { methods: ["sessions.catalog.list"] },
|
||||
} as ApplicationGatewaySnapshot["hello"];
|
||||
gateway.publish({ hello });
|
||||
const { sidebar } = await mountSidebar(
|
||||
gateway.gateway,
|
||||
createSessions("main", ["agent:main:main"]),
|
||||
);
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
|
||||
gateway.publish({ phase: "reconnecting", hello: null });
|
||||
await sidebar.updateComplete;
|
||||
gateway.publish({ phase: "connected", hello });
|
||||
await sidebar.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
legacyFallback.resolve(catalogPage([]));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(request).toHaveBeenLastCalledWith("sessions.catalog.list", {
|
||||
agentId: "main",
|
||||
limitPerHost: 40,
|
||||
progressId: expect.any(String),
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1409,6 +1409,7 @@ function installControlUiMockGateway(
|
||||
readonly protocol = "";
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
readonly url: string;
|
||||
private tickTimer: number | null = null;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
@@ -1452,6 +1453,10 @@ function installControlUiMockGateway(
|
||||
return;
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
if (this.tickTimer !== null) {
|
||||
window.clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
sessionMessageSubscriptions.clear();
|
||||
stopRepeatingSessionEvents();
|
||||
this.dispatchEvent(new CloseEvent("close", { code, reason }));
|
||||
@@ -1481,6 +1486,11 @@ function installControlUiMockGateway(
|
||||
? { id, ok: false, error: mockError, type: "res" }
|
||||
: { id, ok: true, payload, type: "res" },
|
||||
);
|
||||
if (!mockError && method === "connect" && this.readyState === MockWebSocket.OPEN) {
|
||||
this.tickTimer = window.setInterval(() => {
|
||||
this.deliver({ event: "tick", payload: {}, seq: ++seq, type: "event" });
|
||||
}, 30_000);
|
||||
}
|
||||
if (!mockError) {
|
||||
updateSessionMessageSubscription(method, frame.params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user