From 4113982fa86eecd1bb44885fe2fa05d87f9422b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 22 Jun 2026 03:24:51 +0200 Subject: [PATCH] fix(scripts): clamp memory fd repro timers --- scripts/check-memory-fd-repro.mjs | 46 +++++++++++++++---- test/scripts/check-memory-fd-repro.test.ts | 53 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/scripts/check-memory-fd-repro.mjs b/scripts/check-memory-fd-repro.mjs index 518aa44016d..7e3f1c580be 100644 --- a/scripts/check-memory-fd-repro.mjs +++ b/scripts/check-memory-fd-repro.mjs @@ -27,6 +27,7 @@ const ISSUE_FILE_COUNTS = [ const ISSUE_MEMORY_FILE_COUNT = ISSUE_FILE_COUNTS.reduce((sum, [, count]) => sum + count, 0); const DEFAULT_FILE_COUNT = 512; const DEFAULT_MAX_WORKSPACE_REG_FDS = process.platform === "darwin" ? 8 : 64; +const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; /** * Maximum gateway-ready output tail retained while waiting for startup. */ @@ -134,6 +135,24 @@ function readPositiveNumberEnv(name, fallback) { return raw == null || raw.trim() === "" ? fallback : readPositiveNumber(raw, name); } +function clampTimerTimeoutMs(valueMs, minMs = 1) { + const min = Math.max(0, Math.floor(minMs)); + const value = Number.isFinite(valueMs) ? valueMs : min; + return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS); +} + +function readTimerTimeoutNumber(value, label, minMs = 1) { + const parsed = minMs > 0 ? readPositiveNumber(value, label) : readNumber(value, label); + return clampTimerTimeoutMs(parsed, minMs); +} + +function readTimerTimeoutNumberEnv(name, fallback, minMs = 1) { + const raw = process.env[name]; + return raw == null || raw.trim() === "" + ? clampTimerTimeoutMs(fallback, minMs) + : readTimerTimeoutNumber(raw, name, minMs); +} + /** * Parses memory FD repro CLI arguments and environment fallbacks. */ @@ -192,13 +211,13 @@ export function parseArgs(argv) { options.minLeakedFds = readPositiveNumber(readValue(), "--min-leaked-fds"); break; case "--invoke-timeout-ms": - options.invokeTimeoutMs = readPositiveNumber(readValue(), "--invoke-timeout-ms"); + options.invokeTimeoutMs = readTimerTimeoutNumber(readValue(), "--invoke-timeout-ms"); break; case "--sample-delay-ms": - options.sampleDelayMs = readNumber(readValue(), "--sample-delay-ms"); + options.sampleDelayMs = readTimerTimeoutNumber(readValue(), "--sample-delay-ms", 0); break; case "--settle-delay-ms": - options.settleDelayMs = readNumber(readValue(), "--settle-delay-ms"); + options.settleDelayMs = readTimerTimeoutNumber(readValue(), "--settle-delay-ms", 0); break; case "--output-dir": options.outputDir = path.resolve(readValue()); @@ -222,9 +241,17 @@ export function parseArgs(argv) { "OPENCLAW_MEMORY_FD_REPRO_MAX_WORKSPACE_REG_FDS", DEFAULT_MAX_WORKSPACE_REG_FDS, ); - options.invokeTimeoutMs ??= readPositiveNumberEnv("OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS", 30_000); - options.sampleDelayMs ??= readNumberEnv("OPENCLAW_MEMORY_FD_REPRO_SAMPLE_DELAY_MS", 1_000); - options.settleDelayMs ??= readNumberEnv("OPENCLAW_MEMORY_FD_REPRO_SETTLE_DELAY_MS", 5_000); + options.invokeTimeoutMs ??= readTimerTimeoutNumberEnv("OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS", 30_000); + options.sampleDelayMs ??= readTimerTimeoutNumberEnv( + "OPENCLAW_MEMORY_FD_REPRO_SAMPLE_DELAY_MS", + 1_000, + 0, + ); + options.settleDelayMs ??= readTimerTimeoutNumberEnv( + "OPENCLAW_MEMORY_FD_REPRO_SETTLE_DELAY_MS", + 5_000, + 0, + ); if (!Number.isFinite(options.fileCount) || options.fileCount <= 0) { throw new Error("file count must be greater than 0"); } @@ -243,7 +270,7 @@ function logStep(message) { function sleep(ms) { return new Promise((resolve) => { - setTimeout(resolve, ms); + setTimeout(resolve, clampTimerTimeoutMs(ms, 0)); }); } @@ -676,9 +703,10 @@ export function classifyMemorySearchInvokeResponse({ httpOk, status, bodyText }) }; } -async function invokeMemorySearch({ port, token, timeoutMs }) { +export async function invokeMemorySearch({ port, token, timeoutMs }) { + const resolvedTimeoutMs = clampTimerTimeoutMs(timeoutMs); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs); const startedAt = Date.now(); try { const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, { diff --git a/test/scripts/check-memory-fd-repro.test.ts b/test/scripts/check-memory-fd-repro.test.ts index 4d536b47f36..19e6001ba12 100644 --- a/test/scripts/check-memory-fd-repro.test.ts +++ b/test/scripts/check-memory-fd-repro.test.ts @@ -1,8 +1,10 @@ // Check Memory Fd Repro tests cover check memory fd repro script behavior. import { EventEmitter } from "node:events"; import fs from "node:fs"; +import { createServer, type Server } from "node:http"; import os from "node:os"; import path from "node:path"; +import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { describe, expect, it, vi } from "vitest"; import { GATEWAY_READY_OUTPUT_MAX_CHARS, @@ -10,6 +12,7 @@ import { MEMORY_SEARCH_RESPONSE_MAX_BYTES, classifyMemorySearchInvokeResponse, hasChildExited, + invokeMemorySearch, parseArgs, readBoundedResponseText, readNumber, @@ -21,6 +24,21 @@ import { } from "../../scripts/check-memory-fd-repro.mjs"; import { withEnv } from "../../src/test-utils/env.js"; +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not expose a TCP port"); + } + return address.port; +} + describe("check-memory-fd-repro", () => { it("parses file, fd, and timing limits as strict integers", () => { expect(readNumber("0", "limit")).toBe(0); @@ -40,13 +58,17 @@ describe("check-memory-fd-repro", () => { OPENCLAW_MEMORY_FD_REPRO_FILES: "17", OPENCLAW_MEMORY_FD_REPRO_MAX_WORKSPACE_REG_FDS: "0", OPENCLAW_MEMORY_FD_REPRO_SAMPLE_DELAY_MS: "0", + OPENCLAW_MEMORY_FD_REPRO_SETTLE_DELAY_MS: String(MAX_TIMER_TIMEOUT_MS + 1), + OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS: String(MAX_TIMER_TIMEOUT_MS + 1), }, () => parseArgs([]), ), ).toMatchObject({ fileCount: 17, + invokeTimeoutMs: MAX_TIMER_TIMEOUT_MS, maxWorkspaceRegFds: 0, sampleDelayMs: 0, + settleDelayMs: MAX_TIMER_TIMEOUT_MS, }); expect(() => @@ -109,6 +131,37 @@ describe("check-memory-fd-repro", () => { }); }); + it("clamps oversized memory_search invoke timers before scheduling", async () => { + const server = createServer((_request, response) => { + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + ok: true, + result: { + content: [{ type: "text", text: JSON.stringify({ results: [] }) }], + }, + }), + ); + }, 25); + }); + const port = await listen(server); + try { + await expect( + invokeMemorySearch({ + port, + token: "test-token", + timeoutMs: MAX_TIMER_TIMEOUT_MS + 1, + }), + ).resolves.toMatchObject({ + gatewayOk: true, + ok: true, + resultCount: 0, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("uses a fast matching probe query instead of a no-hit stress query", () => { expect(MEMORY_SEARCH_PROBE_QUERY).toBe("Top-level memory file"); expect(MEMORY_SEARCH_PROBE_QUERY).not.toContain("nomatch");