mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 08:01:40 +00:00
fix(stability): patch regex retries and timeout abort handling
This commit is contained in:
committed by
Peter Steinberger
parent
99a2f5379e
commit
1051f42f96
@@ -16,6 +16,8 @@ export type SignalDaemonOpts = {
|
||||
export type SignalDaemonHandle = {
|
||||
pid?: number;
|
||||
stop: () => void;
|
||||
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
isExited: () => boolean;
|
||||
};
|
||||
|
||||
export function classifySignalCliLogLine(line: string): "log" | "error" | null {
|
||||
@@ -83,17 +85,51 @@ export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
|
||||
});
|
||||
const log = opts.runtime?.log ?? (() => {});
|
||||
const error = opts.runtime?.error ?? (() => {});
|
||||
let exited = false;
|
||||
let settledExit = false;
|
||||
let resolveExit!: (value: { code: number | null; signal: NodeJS.Signals | null }) => void;
|
||||
const exitedPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolve) => {
|
||||
resolveExit = resolve;
|
||||
},
|
||||
);
|
||||
const settleExit = (value: { code: number | null; signal: NodeJS.Signals | null }) => {
|
||||
if (settledExit) {
|
||||
return;
|
||||
}
|
||||
settledExit = true;
|
||||
exited = true;
|
||||
resolveExit(value);
|
||||
};
|
||||
|
||||
bindSignalCliOutput({ stream: child.stdout, log, error });
|
||||
bindSignalCliOutput({ stream: child.stderr, log, error });
|
||||
child.once("exit", (code, signal) => {
|
||||
settleExit({
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
error(
|
||||
`signal-cli daemon exited (code=${String(code ?? "null")} signal=${String(signal ?? "null")})`,
|
||||
);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settleExit({
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
error(`signal-cli spawn error: ${String(err)}`);
|
||||
settleExit({ code: null, signal: null });
|
||||
});
|
||||
|
||||
return {
|
||||
pid: child.pid ?? undefined,
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
stop: () => {
|
||||
if (!child.killed) {
|
||||
if (!child.killed && !exited) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
updateLastRouteMock,
|
||||
upsertPairingRequestMock,
|
||||
waitForTransportReadyMock,
|
||||
spawnSignalDaemonMock,
|
||||
} = getSignalToolResultTestMocks();
|
||||
|
||||
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
|
||||
@@ -176,7 +177,7 @@ describe("monitorSignalProvider tool results", () => {
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 150,
|
||||
runtime,
|
||||
abortSignal: abortController.signal,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -212,6 +213,39 @@ describe("monitorSignalProvider tool results", () => {
|
||||
expectWaitForTransportReadyTimeout(120_000);
|
||||
});
|
||||
|
||||
it("fails fast when auto-started signal daemon exits during startup", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
spawnSignalDaemonMock.mockReturnValueOnce({
|
||||
stop: vi.fn(),
|
||||
exited: Promise.resolve({ code: 1, signal: null }),
|
||||
isExited: () => true,
|
||||
});
|
||||
waitForTransportReadyMock.mockImplementationOnce(
|
||||
async (params: { abortSignal?: AbortSignal | null }) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (params.abortSignal?.aborted) {
|
||||
reject(params.abortSignal.reason);
|
||||
return;
|
||||
}
|
||||
params.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(params.abortSignal?.reason ?? new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
runtime,
|
||||
}),
|
||||
).rejects.toThrow(/signal daemon exited/i);
|
||||
});
|
||||
|
||||
it("skips tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ type SignalToolResultTestMocks = {
|
||||
streamMock: MockFn;
|
||||
signalCheckMock: MockFn;
|
||||
signalRpcRequestMock: MockFn;
|
||||
spawnSignalDaemonMock: MockFn;
|
||||
};
|
||||
|
||||
const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
@@ -24,6 +25,7 @@ const upsertPairingRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const streamMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalCheckMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalRpcRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
|
||||
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
|
||||
return {
|
||||
@@ -36,6 +38,7 @@ export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
|
||||
streamMock,
|
||||
signalCheckMock,
|
||||
signalRpcRequestMock,
|
||||
spawnSignalDaemonMock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ vi.mock("./client.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", () => ({
|
||||
spawnSignalDaemon: vi.fn(() => ({ stop: vi.fn() })),
|
||||
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/transport-ready.js", () => ({
|
||||
@@ -107,6 +110,11 @@ export function installSignalToolResultTestHooks() {
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
spawnSignalDaemonMock.mockReset().mockReturnValue({
|
||||
stop: vi.fn(),
|
||||
exited: new Promise(() => {}),
|
||||
isExited: () => false,
|
||||
});
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
@@ -47,6 +47,46 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
function mergeAbortSignals(
|
||||
a?: AbortSignal,
|
||||
b?: AbortSignal,
|
||||
): { signal?: AbortSignal; dispose: () => void } {
|
||||
if (!a && !b) {
|
||||
return { signal: undefined, dispose: () => {} };
|
||||
}
|
||||
if (!a) {
|
||||
return { signal: b, dispose: () => {} };
|
||||
}
|
||||
if (!b) {
|
||||
return { signal: a, dispose: () => {} };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const abortFrom = (source: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(source.reason);
|
||||
}
|
||||
};
|
||||
if (a.aborted) {
|
||||
abortFrom(a);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
if (b.aborted) {
|
||||
abortFrom(b);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
const onAbortA = () => abortFrom(a);
|
||||
const onAbortB = () => abortFrom(b);
|
||||
a.addEventListener("abort", onAbortA, { once: true });
|
||||
b.addEventListener("abort", onAbortB, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
a.removeEventListener("abort", onAbortA);
|
||||
b.removeEventListener("abort", onAbortB);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return normalizeStringEntries(raw);
|
||||
}
|
||||
@@ -286,6 +326,9 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||
let daemonExitError: Error | undefined;
|
||||
const daemonAbortController = new AbortController();
|
||||
const mergedAbort = mergeAbortSignals(opts.abortSignal, daemonAbortController.signal);
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
@@ -303,6 +346,14 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
void daemonHandle.exited.then((exit) => {
|
||||
daemonExitError = new Error(
|
||||
`signal daemon exited (code=${String(exit.code ?? "null")} signal=${String(exit.signal ?? "null")})`,
|
||||
);
|
||||
if (!daemonAbortController.signal.aborted) {
|
||||
daemonAbortController.abort(daemonExitError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
@@ -314,12 +365,15 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
if (daemonHandle) {
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: opts.abortSignal,
|
||||
abortSignal: mergedAbort.signal,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
});
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvent = createSignalEventHandler({
|
||||
@@ -353,7 +407,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
await runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: opts.abortSignal,
|
||||
abortSignal: mergedAbort.signal,
|
||||
runtime,
|
||||
onEvent: (event) => {
|
||||
void handleEvent(event).catch((err) => {
|
||||
@@ -361,12 +415,16 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
});
|
||||
},
|
||||
});
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
} catch (err) {
|
||||
if (opts.abortSignal?.aborted) {
|
||||
if (opts.abortSignal?.aborted && !daemonExitError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
mergedAbort.dispose();
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonHandle?.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user