Skip to content

Commit fa09d43

Browse files
committed
fix: add timeout to parent-fork token count resolution (fixes #101718)
resolveParentForkTokenCount reads the parent transcript to estimate token count when forking a session, but has no timeout. On large transcripts or slow filesystems (NFS, FUSE), this blocks session creation indefinitely, accumulating blocked async contexts on the gateway. Wrap the runtime call in resolveParentForkTokenCount with withTimeout(..., 2_000) so the function returns undefined within bounded time. resolveParentForkDecision already handles undefined parentTokens correctly (returns fork without a token estimate), which is the safe default vs. blocking indefinitely. Includes regression tests for the fast-resolve, timeout, and too-large paths.
1 parent 3d20614 commit fa09d43

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

src/auto-reply/reply/session-fork.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import type { OpenClawConfig } from "../../config/types.openclaw.js";
7-
import { forkSessionEntryFromParent } from "./session-fork.js";
7+
import { forkSessionEntryFromParent, resolveParentForkDecision } from "./session-fork.js";
88

99
const runtimeMocks = vi.hoisted(() => ({
1010
forkSessionFromParentRuntime: vi.fn(),
@@ -85,3 +85,49 @@ describe("forkSessionEntryFromParent", () => {
8585
expect(stored[sessionKey]?.sessionFile).toBe(path.join(activeStoreDir, "forked-session.jsonl"));
8686
});
8787
});
88+
89+
describe("resolveParentForkDecision", () => {
90+
it("returns fork with parentTokens when the runtime resolves quickly", async () => {
91+
runtimeMocks.resolveParentForkTokenCountRuntime.mockResolvedValue(50_000);
92+
93+
const decision = await resolveParentForkDecision({
94+
parentEntry: { sessionId: "parent", updatedAt: 1 },
95+
agentId: "main",
96+
storePath: "/tmp/sessions.json",
97+
});
98+
99+
expect(decision.status).toBe("fork");
100+
expect(decision).toHaveProperty("parentTokens", 50_000);
101+
});
102+
103+
it("returns fork without parentTokens when the runtime hangs (timeout protection)", async () => {
104+
// Simulate a hung filesystem runtime that never resolves.
105+
runtimeMocks.resolveParentForkTokenCountRuntime.mockReturnValue(new Promise<number>(() => {}));
106+
107+
const decision = await resolveParentForkDecision({
108+
parentEntry: { sessionId: "parent", updatedAt: 1 },
109+
agentId: "main",
110+
storePath: "/tmp/sessions.json",
111+
});
112+
113+
// Must still return a decision (not throw/timeout the caller).
114+
// Fork proceeds without a token estimate rather than blocking (#101718).
115+
expect(decision.status).toBe("fork");
116+
expect(decision).not.toHaveProperty("parentTokens");
117+
});
118+
119+
it("returns skip when parent is too large", async () => {
120+
runtimeMocks.resolveParentForkTokenCountRuntime.mockResolvedValue(200_000);
121+
122+
const decision = await resolveParentForkDecision({
123+
parentEntry: { sessionId: "parent", updatedAt: 1 },
124+
agentId: "main",
125+
storePath: "/tmp/sessions.json",
126+
});
127+
128+
expect(decision.status).toBe("skip");
129+
expect(decision.reason).toBe("parent-too-large");
130+
expect(decision.parentTokens).toBe(200_000);
131+
expect(decision.maxTokens).toBe(100_000);
132+
});
133+
});

src/auto-reply/reply/session-fork.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolveStorePath } from "../../config/sessions/paths.js";
33
import { updateSessionStore } from "../../config/sessions/store.js";
44
import { mergeSessionEntry, type SessionEntry } from "../../config/sessions/types.js";
55
import type { OpenClawConfig } from "../../config/types.openclaw.js";
6+
import { withTimeout } from "../../infra/fs-safe.js";
67
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
78

89
/**
@@ -11,6 +12,7 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js";
1112
* See #26905.
1213
*/
1314
const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000;
15+
const PARENT_FORK_TOKEN_TIMEOUT_MS = 2_000;
1416
const sessionForkRuntimeLoader = createLazyImportLoader(() => import("./session-fork.runtime.js"));
1517

1618
export type ParentForkDecision =
@@ -296,5 +298,9 @@ async function resolveParentForkTokenCount(params: {
296298
storePath: string;
297299
}): Promise<number | undefined> {
298300
const runtime = await loadSessionForkRuntime();
299-
return runtime.resolveParentForkTokenCountRuntime(params);
301+
return await withTimeout(
302+
runtime.resolveParentForkTokenCountRuntime(params),
303+
PARENT_FORK_TOKEN_TIMEOUT_MS,
304+
"resolveParentForkTokenCount",
305+
).catch(() => undefined);
300306
}

0 commit comments

Comments
 (0)