Skip to content

Commit e80b0b5

Browse files
authored
fix(reef): stop leaking the inbox loop across channel crash restarts (#110870)
* fix(reef): stop leaking the inbox loop across channel crash restarts * fix(reef): move channel lifecycle helper to its own module for lint/deadcode gates
1 parent 90a6256 commit e80b0b5

5 files changed

Lines changed: 186 additions & 16 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { abortableSleep } from "./transport.js";
2+
3+
const REEF_RECONCILE_INTERVAL_MS = 30_000;
4+
5+
// One abort scope owns both account loops. If either branch throws, the inbox
6+
// loop must be torn down and awaited before startAccount settles: a leaked
7+
// loop keeps reconnecting as this handle, fights the replacement instance for
8+
// the single relay inbox socket, and drives the relay into rate limiting.
9+
export async function runReefChannelLifecycle(params: {
10+
parentSignal: AbortSignal;
11+
startInbox: (signal: AbortSignal) => Promise<void>;
12+
reconcile: () => Promise<void>;
13+
onReconcileError: (error: unknown) => void;
14+
reconcileIntervalMs?: number;
15+
}): Promise<void> {
16+
const lifecycle = new AbortController();
17+
const onParentAbort = () => lifecycle.abort();
18+
params.parentSignal.addEventListener("abort", onParentAbort, { once: true });
19+
if (params.parentSignal.aborted) {
20+
// Listeners added after an abort never fire; inherit the abort directly.
21+
lifecycle.abort();
22+
}
23+
const intervalMs = params.reconcileIntervalMs ?? REEF_RECONCILE_INTERVAL_MS;
24+
const reconciliationLoop = async () => {
25+
while (!lifecycle.signal.aborted) {
26+
await abortableSleep(intervalMs, lifecycle.signal);
27+
if (lifecycle.signal.aborted) {
28+
return;
29+
}
30+
try {
31+
await params.reconcile();
32+
} catch (error) {
33+
// Transient relay failures (429, network) must not crash the channel:
34+
// the crash-restart cycle re-registers the inbox connection and is
35+
// itself what escalates relay rate limiting.
36+
params.onReconcileError(error);
37+
}
38+
}
39+
};
40+
const inboxTask = params.startInbox(lifecycle.signal);
41+
try {
42+
await Promise.all([inboxTask, reconciliationLoop()]);
43+
} finally {
44+
lifecycle.abort();
45+
params.parentSignal.removeEventListener("abort", onParentAbort);
46+
// startInbox resolves (never rejects) once its socket work is quiescent,
47+
// so no reconnect loop can outlive this account instance.
48+
await inboxTask;
49+
}
50+
}

extensions/reef/src/channel.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
99
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
1010
import { defaultRuntime } from "openclaw/plugin-sdk/runtime";
11-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1212
import { generateIdentity } from "../protocol/index.js";
13+
import { runReefChannelLifecycle } from "./channel-lifecycle.js";
1314
import { reefPlugin } from "./channel.js";
1415
import { resolveReefConfig } from "./config-schema.js";
1516
import { resolveReefInboundDispatchContent } from "./inbound.js";
@@ -103,3 +104,96 @@ describe("Reef conversation directory", () => {
103104
).resolves.toEqual([{ kind: "user", id: "molty", name: "@molty's agent", handle: "@molty" }]);
104105
});
105106
});
107+
108+
describe("Reef channel lifecycle", () => {
109+
function hangingInbox() {
110+
const seen: AbortSignal[] = [];
111+
let settled = false;
112+
const startInbox = (signal: AbortSignal) => {
113+
seen.push(signal);
114+
return new Promise<void>((resolve) => {
115+
const done = () => {
116+
settled = true;
117+
resolve();
118+
};
119+
if (signal.aborted) {
120+
done();
121+
return;
122+
}
123+
signal.addEventListener("abort", done, { once: true });
124+
});
125+
};
126+
return { startInbox, seen, isSettled: () => settled };
127+
}
128+
129+
it("keeps running when a periodic reconcile fails", async () => {
130+
const parent = new AbortController();
131+
const inbox = hangingInbox();
132+
const errors: unknown[] = [];
133+
let reconciles = 0;
134+
const lifecycle = runReefChannelLifecycle({
135+
parentSignal: parent.signal,
136+
startInbox: inbox.startInbox,
137+
reconcile: async () => {
138+
reconciles += 1;
139+
throw new Error("rate_limited");
140+
},
141+
onReconcileError: (error) => errors.push(error),
142+
reconcileIntervalMs: 5,
143+
});
144+
await vi.waitFor(() => {
145+
expect(reconciles).toBeGreaterThanOrEqual(2);
146+
});
147+
expect(errors.length).toBeGreaterThanOrEqual(2);
148+
expect(inbox.isSettled()).toBe(false);
149+
parent.abort();
150+
await lifecycle;
151+
expect(inbox.isSettled()).toBe(true);
152+
});
153+
154+
it("tears down the inbox loop before settling when a loop branch throws", async () => {
155+
const parent = new AbortController();
156+
const inbox = hangingInbox();
157+
// Simulate a non-transport crash escaping the lifecycle (reconcile errors
158+
// are contained, so throw from the error hook itself).
159+
const lifecycle = runReefChannelLifecycle({
160+
parentSignal: parent.signal,
161+
startInbox: inbox.startInbox,
162+
reconcile: async () => {
163+
throw new Error("boom");
164+
},
165+
onReconcileError: () => {
166+
throw new Error("fatal");
167+
},
168+
reconcileIntervalMs: 5,
169+
});
170+
await expect(lifecycle).rejects.toThrow("fatal");
171+
// The rejection must not leave the inbox reconnect loop running: its
172+
// signal is aborted and its promise has settled before the caller resumes.
173+
expect(inbox.seen[0]?.aborted).toBe(true);
174+
expect(inbox.isSettled()).toBe(true);
175+
});
176+
});
177+
178+
describe("Reef channel lifecycle abort inheritance", () => {
179+
it("settles immediately when the parent signal is already aborted", async () => {
180+
const parent = new AbortController();
181+
parent.abort();
182+
const seen: AbortSignal[] = [];
183+
await runReefChannelLifecycle({
184+
parentSignal: parent.signal,
185+
startInbox: (signal) => {
186+
seen.push(signal);
187+
return signal.aborted
188+
? Promise.resolve()
189+
: new Promise<void>((resolve) => {
190+
signal.addEventListener("abort", () => resolve(), { once: true });
191+
});
192+
},
193+
reconcile: async () => {},
194+
onReconcileError: () => {},
195+
reconcileIntervalMs: 5,
196+
});
197+
expect(seen[0]?.aborted).toBe(true);
198+
});
199+
});

extensions/reef/src/channel.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type ChannelPlugin,
1111
} from "openclaw/plugin-sdk/core";
1212
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
13+
import { runReefChannelLifecycle } from "./channel-lifecycle.js";
1314
import {
1415
ReefChannelConfigSchema,
1516
autonomyBudget,
@@ -31,12 +32,7 @@ import { isRephrasedReefResend } from "./rejection-resend.js";
3132
import { getActiveReef, getOptionalReefRuntime, getReefRuntime, setActiveReef } from "./runtime.js";
3233
import { reefSetupAdapter, reefSetupWizard } from "./setup.js";
3334
import { assertReefIdentityBinding, loadKeys, openStores, ReefInboxCursorStore } from "./state.js";
34-
import {
35-
ReefInboxConnection,
36-
ReefTransportClient,
37-
abortableSleep,
38-
createReefWebSocket,
39-
} from "./transport.js";
35+
import { ReefInboxConnection, ReefTransportClient, createReefWebSocket } from "./transport.js";
4036
import { isReefPairingApprovalToken, openReefTrustStore } from "./trust-store.js";
4137
import type { ReefAccount, ReefIngressMessage } from "./types.js";
4238

@@ -454,16 +450,14 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
454450
},
455451
},
456452
);
457-
const reconciliationLoop = async () => {
458-
while (!ctx.abortSignal.aborted) {
459-
await abortableSleep(30_000, ctx.abortSignal);
460-
if (!ctx.abortSignal.aborted) {
461-
await reconcile();
462-
}
463-
}
464-
};
465453
try {
466-
await Promise.all([inbox.start(ctx.abortSignal), reconciliationLoop()]);
454+
await runReefChannelLifecycle({
455+
parentSignal: ctx.abortSignal,
456+
startInbox: (signal) => inbox.start(signal),
457+
reconcile,
458+
onReconcileError: (error) =>
459+
ctx.log?.error?.(`reef friend reconcile failed: ${String(error)}`),
460+
});
467461
} finally {
468462
ctx.setStatus({ accountId: "default", running: false, connected: false });
469463
}

src/gateway/server-channels.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,28 @@ describe("server-channels auto restart", () => {
309309
expect(startAccount).toHaveBeenCalledTimes(11);
310310
});
311311

312+
it("aborts the crashed task's signal before starting its replacement", async () => {
313+
const signals: AbortSignal[] = [];
314+
const startAccount = vi.fn(async (ctx: ChannelGatewayContext<TestAccount>) => {
315+
signals.push(ctx.abortSignal);
316+
throw new Error("crash");
317+
});
318+
installTestRegistry(createTestPlugin({ startAccount }));
319+
const manager = createManager();
320+
321+
await manager.startChannels();
322+
await advanceTimersUntil(
323+
() => startAccount.mock.calls.length >= 2,
324+
"expected a crash-loop restart",
325+
{ stepMs: 10, maxMs: 500 },
326+
);
327+
328+
// A crashed startAccount can leave background work racing on its signal
329+
// (e.g. a reconnect loop). The replacement must never overlap that lifetime.
330+
expect(signals[0]?.aborted).toBe(true);
331+
expect(signals[1]?.aborted).toBe(false);
332+
});
333+
312334
it.each(["resolve", "reject"] as const)(
313335
"resets the restart counter after a stable run that ends with %s",
314336
async (outcome) => {

src/gateway/server-channels.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
789789
if (store.aborts.get(id) === abort) {
790790
store.aborts.delete(id);
791791
}
792+
// The settled task may have left background work racing on this
793+
// signal. Abort before the replacement starts so two account
794+
// instances can never share a live lifetime.
795+
abort.abort();
792796
try {
793797
await startChannelInternal(channelId, id, {
794798
preserveManualStop: true,
@@ -838,6 +842,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
838842
if (store.aborts.get(id) === abort) {
839843
store.aborts.delete(id);
840844
}
845+
// See the timed-out-stop restart above: never start the crash
846+
// replacement while the predecessor's signal is unaborted.
847+
abort.abort();
841848
await startChannelInternal(channelId, id, {
842849
preserveRestartAttempts: true,
843850
preserveManualStop: true,
@@ -853,6 +860,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
853860
if (store.aborts.get(id) === abort) {
854861
store.aborts.delete(id);
855862
}
863+
// Terminal paths (give-up, terminal disconnect, manual stop) end
864+
// here without a restart; leave no unaborted lifetime behind.
865+
abort.abort();
856866
});
857867
function isCurrentTask() {
858868
return store.tasks.get(id) === trackedPromise;

0 commit comments

Comments
 (0)