Skip to content

Commit 329dad2

Browse files
committed
fix(test): bound config reload log polling
1 parent d6949d5 commit 329dad2

3 files changed

Lines changed: 222 additions & 24 deletions

File tree

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,40 @@
1-
import fs from "node:fs";
1+
import { createConfigReloadLogScanner } from "./log-scanner.mjs";
22

33
const logPath = process.env.OPENCLAW_CONFIG_RELOAD_LOG_PATH ?? "/tmp/config-reload-e2e.log";
44
const deadlineMs = Date.now() + Number(process.env.OPENCLAW_CONFIG_RELOAD_LOG_TIMEOUT_MS ?? 30_000);
5+
const maxReadBytes = Number.parseInt(
6+
process.env.OPENCLAW_CONFIG_RELOAD_LOG_MAX_READ_BYTES ?? `${256 * 1024}`,
7+
10,
8+
);
59

610
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7-
8-
function readLog() {
9-
return fs.readFileSync(logPath, "utf8");
10-
}
11-
12-
function inspectLog(log) {
13-
const lines = log.split("\n");
14-
const reloadLines = lines.filter((line) =>
15-
line.includes("config change detected; evaluating reload"),
16-
);
17-
const restartLines = lines.filter((line) =>
18-
line.includes("config change requires gateway restart"),
19-
);
20-
return { lines, reloadLines, restartLines };
21-
}
22-
23-
let log = "";
24-
let result = { lines: [], reloadLines: [], restartLines: [] };
11+
const scanner = createConfigReloadLogScanner(logPath, {
12+
maxReadBytes: Number.isSafeInteger(maxReadBytes) && maxReadBytes > 0 ? maxReadBytes : 256 * 1024,
13+
tailLineLimit: 160,
14+
});
15+
let result = { reloadLines: [], restartLines: [], tailLines: [] };
2516

2617
while (Date.now() < deadlineMs) {
27-
log = readLog();
28-
result = inspectLog(log);
18+
result = scanner.scan();
2919
if (result.restartLines.length > 0 || result.reloadLines.length > 0) {
3020
break;
3121
}
3222
await sleep(500);
3323
}
3424

3525
if (result.restartLines.length > 0) {
36-
console.error(result.lines.slice(-160).join("\n"));
26+
console.error(result.tailLines.join("\n"));
3727
throw new Error("unexpected restart-required reload line found");
3828
}
3929
for (const line of result.reloadLines) {
4030
for (const needle of ["gateway.auth.token", "plugins.entries.firecrawl.config.webFetch"]) {
4131
if (line.includes(needle)) {
42-
console.error(result.lines.slice(-160).join("\n"));
32+
console.error(result.tailLines.join("\n"));
4333
throw new Error(`runtime-only path appeared in reload diff: ${needle}`);
4434
}
4535
}
4636
}
4737
if (result.reloadLines.length === 0) {
48-
console.error(result.lines.slice(-160).join("\n"));
38+
console.error(result.tailLines.join("\n"));
4939
throw new Error("expected config reload detection log after metadata write");
5040
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import fs from "node:fs";
2+
3+
const DEFAULT_MAX_READ_BYTES = 256 * 1024;
4+
const DEFAULT_TAIL_LINE_LIMIT = 160;
5+
const RELOAD_NEEDLE = "config change detected; evaluating reload";
6+
const RESTART_NEEDLE = "config change requires gateway restart";
7+
8+
function positiveInteger(value, fallback) {
9+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
10+
}
11+
12+
function readSlice(filePath, start, length) {
13+
if (length <= 0) {
14+
return "";
15+
}
16+
const fd = fs.openSync(filePath, "r");
17+
try {
18+
const buffer = Buffer.allocUnsafe(length);
19+
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
20+
return buffer.subarray(0, bytesRead).toString("utf8");
21+
} finally {
22+
fs.closeSync(fd);
23+
}
24+
}
25+
26+
export function inspectConfigReloadLogLine(line) {
27+
return {
28+
reload: line.includes(RELOAD_NEEDLE),
29+
restart: line.includes(RESTART_NEEDLE),
30+
};
31+
}
32+
33+
export function createConfigReloadLogScanner(logPath, options = {}) {
34+
const maxReadBytes = positiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
35+
const tailLineLimit = positiveInteger(options.tailLineLimit, DEFAULT_TAIL_LINE_LIMIT);
36+
let offset = 0;
37+
let pending = "";
38+
let tailLines = [];
39+
const reloadLines = [];
40+
const restartLines = [];
41+
42+
return {
43+
scan() {
44+
if (!fs.existsSync(logPath)) {
45+
return { reloadLines, restartLines, tailLines };
46+
}
47+
48+
const stats = fs.statSync(logPath);
49+
if (!stats.isFile()) {
50+
return { reloadLines, restartLines, tailLines };
51+
}
52+
if (stats.size < offset) {
53+
offset = 0;
54+
pending = "";
55+
tailLines = [];
56+
reloadLines.length = 0;
57+
restartLines.length = 0;
58+
}
59+
if (stats.size === offset) {
60+
return { reloadLines, restartLines, tailLines };
61+
}
62+
63+
let start = offset;
64+
let discardFirstLine = false;
65+
let clamped = false;
66+
if (start === 0 && stats.size > maxReadBytes) {
67+
start = stats.size - maxReadBytes;
68+
pending = "";
69+
clamped = true;
70+
} else if (stats.size - start > maxReadBytes) {
71+
start = stats.size - maxReadBytes;
72+
pending = "";
73+
clamped = true;
74+
}
75+
if (clamped && start > 0) {
76+
discardFirstLine = readSlice(logPath, start - 1, 1) !== "\n";
77+
}
78+
79+
const text = readSlice(logPath, start, stats.size - start);
80+
offset = stats.size;
81+
let chunk = pending + text;
82+
if (discardFirstLine) {
83+
const newlineIndex = chunk.indexOf("\n");
84+
if (newlineIndex === -1) {
85+
pending = "";
86+
return { reloadLines, restartLines, tailLines };
87+
}
88+
chunk = chunk.slice(newlineIndex + 1);
89+
}
90+
91+
const lines = chunk.split("\n");
92+
pending = lines.pop() ?? "";
93+
for (const line of lines) {
94+
const trimmed = line.replace(/\r$/u, "");
95+
tailLines.push(trimmed);
96+
const match = inspectConfigReloadLogLine(trimmed);
97+
if (match.reload) {
98+
reloadLines.push(trimmed);
99+
}
100+
if (match.restart) {
101+
restartLines.push(trimmed);
102+
}
103+
}
104+
if (tailLines.length > tailLineLimit) {
105+
tailLines = tailLines.slice(-tailLineLimit);
106+
}
107+
return { reloadLines, restartLines, tailLines };
108+
},
109+
};
110+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { createConfigReloadLogScanner } from "../../scripts/e2e/lib/config-reload/log-scanner.mjs";
6+
7+
const tempRoots: string[] = [];
8+
9+
function makeTempRoot(): string {
10+
const root = mkdtempSync(path.join(tmpdir(), "openclaw-config-reload-log-"));
11+
tempRoots.push(root);
12+
return root;
13+
}
14+
15+
afterEach(() => {
16+
for (const root of tempRoots.splice(0)) {
17+
rmSync(root, { recursive: true, force: true });
18+
}
19+
});
20+
21+
describe("config reload log scanner", () => {
22+
it("keeps previous matches while reading only appended log lines", () => {
23+
const logPath = path.join(makeTempRoot(), "gateway.log");
24+
const scanner = createConfigReloadLogScanner(logPath, {
25+
maxReadBytes: 1024,
26+
tailLineLimit: 4,
27+
});
28+
29+
expect(scanner.scan()).toEqual({ reloadLines: [], restartLines: [], tailLines: [] });
30+
31+
writeFileSync(logPath, "gateway boot\n");
32+
expect(scanner.scan()).toEqual({
33+
reloadLines: [],
34+
restartLines: [],
35+
tailLines: ["gateway boot"],
36+
});
37+
38+
appendFileSync(logPath, "config change detected; evaluating reload: plugins.entries.demo\n");
39+
expect(scanner.scan().reloadLines).toEqual([
40+
"config change detected; evaluating reload: plugins.entries.demo",
41+
]);
42+
43+
appendFileSync(logPath, "later noise\n");
44+
expect(scanner.scan().reloadLines).toEqual([
45+
"config change detected; evaluating reload: plugins.entries.demo",
46+
]);
47+
});
48+
49+
it("preserves partial lines between polls", () => {
50+
const logPath = path.join(makeTempRoot(), "gateway.log");
51+
const scanner = createConfigReloadLogScanner(logPath, {
52+
maxReadBytes: 1024,
53+
tailLineLimit: 4,
54+
});
55+
56+
writeFileSync(logPath, "config change detected");
57+
expect(scanner.scan().reloadLines).toEqual([]);
58+
59+
appendFileSync(logPath, "; evaluating reload: gateway.channelHealthCheckMinutes\n");
60+
expect(scanner.scan().reloadLines).toEqual([
61+
"config change detected; evaluating reload: gateway.channelHealthCheckMinutes",
62+
]);
63+
});
64+
65+
it("starts from a bounded tail of oversized logs", () => {
66+
const logPath = path.join(makeTempRoot(), "gateway.log");
67+
const reloadLine =
68+
"config change detected; evaluating reload: gateway.channelHealthCheckMinutes\n";
69+
writeFileSync(logPath, `${"x".repeat(4096)}\n${reloadLine}`);
70+
71+
const scanner = createConfigReloadLogScanner(logPath, {
72+
maxReadBytes: reloadLine.length,
73+
tailLineLimit: 4,
74+
});
75+
76+
expect(scanner.scan().reloadLines).toEqual([
77+
"config change detected; evaluating reload: gateway.channelHealthCheckMinutes",
78+
]);
79+
});
80+
81+
it("resets accumulated matches when the log rotates", () => {
82+
const logPath = path.join(makeTempRoot(), "gateway.log");
83+
const scanner = createConfigReloadLogScanner(logPath, {
84+
maxReadBytes: 1024,
85+
tailLineLimit: 4,
86+
});
87+
88+
writeFileSync(logPath, "config change detected; evaluating reload: old.path\n");
89+
expect(scanner.scan().reloadLines).toEqual([
90+
"config change detected; evaluating reload: old.path",
91+
]);
92+
93+
writeFileSync(logPath, "config change requires gateway restart: new.path\n");
94+
const result = scanner.scan();
95+
expect(result.reloadLines).toEqual([]);
96+
expect(result.restartLines).toEqual(["config change requires gateway restart: new.path"]);
97+
});
98+
});

0 commit comments

Comments
 (0)