Skip to content

Commit c94da0d

Browse files
fix(logs): preserve log tails across short reads (#105066)
* fix(logs): preserve log tails across short reads * test(logging): track log-tail temp files --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent cea0d98 commit c94da0d

4 files changed

Lines changed: 95 additions & 9 deletions

File tree

src/commands/channels.logs.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ vi.mock("../channels/plugins/index.js", () => ({
2626
import { channelsLogsCommand } from "./channels/logs.js";
2727

2828
const runtime = createTestRuntime();
29+
type PositionalRead = (
30+
buffer: Buffer,
31+
offset: number,
32+
length: number,
33+
position: number | null,
34+
) => Promise<{ bytesRead: number; buffer: Buffer }>;
2935

3036
function logLine(params: { module: string; message: string }) {
3137
return JSON.stringify({
@@ -62,6 +68,7 @@ describe("channelsLogsCommand", () => {
6268
});
6369

6470
afterEach(async () => {
71+
vi.restoreAllMocks();
6572
setLoggerOverride(null);
6673
await fs.rm(tempDir, { recursive: true, force: true });
6774
});
@@ -142,6 +149,33 @@ describe("channelsLogsCommand", () => {
142149
expect(payload.lines.map((line) => line.message)).toEqual(["current sent"]);
143150
});
144151

152+
it("fills short positional reads before parsing channel log lines", async () => {
153+
const realOpen = fs.open.bind(fs);
154+
const readLengths: number[] = [];
155+
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
156+
const handle = await realOpen(...args);
157+
const realRead = handle.read.bind(handle) as PositionalRead;
158+
const shortRead = vi.fn<PositionalRead>((buffer, offset, length, position) => {
159+
readLengths.push(length);
160+
return realRead(buffer, offset, Math.min(length, 4), position);
161+
});
162+
Object.defineProperty(handle, "read", { configurable: true, value: shortRead });
163+
return handle;
164+
});
165+
await fs.writeFile(
166+
logPath,
167+
[
168+
logLine({ module: "gateway/channels/slack/send", message: "first" }),
169+
logLine({ module: "gateway/channels/slack/send", message: "second" }),
170+
].join("\n"),
171+
);
172+
173+
await channelsLogsCommand({ channel: "slack", json: true }, runtime);
174+
175+
expect(readJsonPayload().lines.map((line) => line.message)).toEqual(["first", "second"]);
176+
expect(readLengths.length).toBeGreaterThan(1);
177+
});
178+
145179
it("returns the first line of the tail window when start aligns with a line boundary", async () => {
146180
// MAX_BYTES in readTailLines is 1_000_000. We build a file of 2_000_000 bytes
147181
// made of 10_000 lines each exactly 200 bytes (199 payload + "\n"), so the

src/commands/channels/logs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
55
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
66
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
77
import { getResolvedLoggerSettings } from "../../logging.js";
8-
import { resolveLogFile } from "../../logging/log-tail.js";
8+
import { readLogWindowFully, resolveLogFile } from "../../logging/log-tail.js";
99
import { parseLogLine } from "../../logging/parse-log-line.js";
1010
import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js";
1111
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
@@ -87,8 +87,8 @@ async function readTailLines(file: string, limit: number): Promise<string[]> {
8787
return [];
8888
}
8989
const buffer = Buffer.alloc(length);
90-
const readResult = await handle.read(buffer, 0, length, start);
91-
const text = buffer.toString("utf8", 0, readResult.bytesRead);
90+
const bytesRead = await readLogWindowFully(handle, buffer, start);
91+
const text = buffer.toString("utf8", 0, bytesRead);
9292
let lines = text.split("\n");
9393
if (start > 0 && prefix !== "\n") {
9494
lines = lines.slice(1);

src/logging/log-tail.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
// Log tail tests cover reading, parsing, and limiting recent log entries.
22
import fs from "node:fs/promises";
3-
import os from "node:os";
43
import path from "node:path";
54
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
66
import { resetLogger, setLoggerOverride } from "../logging.js";
77

8+
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
9+
810
const resolvedRedaction = { mode: "tools" as const, patterns: [/custom-secret-[a-z]+/g] };
11+
type PositionalRead = (
12+
buffer: Buffer,
13+
offset: number,
14+
length: number,
15+
position: number | null,
16+
) => Promise<{ bytesRead: number; buffer: Buffer }>;
917

1018
const { redactSensitiveLinesMock, resolveRedactOptionsMock } = vi.hoisted(() => ({
1119
redactSensitiveLinesMock: vi.fn((lines: string[], options?: unknown) =>
@@ -28,6 +36,7 @@ vi.mock("./redact.js", async () => {
2836

2937
describe("readConfiguredLogTail", () => {
3038
afterEach(() => {
39+
vi.restoreAllMocks();
3140
resolveRedactOptionsMock.mockClear();
3241
redactSensitiveLinesMock.mockClear();
3342
resetLogger();
@@ -36,7 +45,7 @@ describe("readConfiguredLogTail", () => {
3645

3746
it("applies redaction once per request across all returned lines", async () => {
3847
const { readConfiguredLogTail } = await import("./log-tail.js");
39-
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-log-tail-"));
48+
const tempDir = tempDirs.make("openclaw-log-tail-");
4049
const file = path.join(tempDir, "openclaw-2026-01-22.log");
4150

4251
await fs.writeFile(file, "custom-secret-abcdefghijklmnopqrstuvwxyz\nsecond line\n");
@@ -51,7 +60,28 @@ describe("readConfiguredLogTail", () => {
5160
resolvedRedaction,
5261
);
5362
expect(result.lines).toEqual(["custom…wxyz", "second line"]);
63+
});
64+
65+
it("fills short positional reads before splitting log lines", async () => {
66+
const { readConfiguredLogTail } = await import("./log-tail.js");
67+
const tempDir = tempDirs.make("openclaw-log-tail-");
68+
const file = path.join(tempDir, "openclaw-2026-01-22.log");
69+
const realOpen = fs.open.bind(fs);
70+
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
71+
const handle = await realOpen(...args);
72+
const realRead = handle.read.bind(handle) as PositionalRead;
73+
const shortRead = vi.fn<PositionalRead>((buffer, offset, length, position) =>
74+
realRead(buffer, offset, Math.min(length, 4), position),
75+
);
76+
Object.defineProperty(handle, "read", { configurable: true, value: shortRead });
77+
return handle;
78+
});
79+
80+
await fs.writeFile(file, "old line\nrecent one\nrecent two\n");
81+
setLoggerOverride({ file });
82+
83+
const result = await readConfiguredLogTail();
5484

55-
await fs.rm(tempDir, { recursive: true, force: true });
85+
expect(result.lines).toEqual(["old line", "recent one", "recent two"]);
5686
});
5787
});

src/logging/log-tail.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Log tail helpers read recent log lines with optional parsing and redaction.
2-
import fs from "node:fs/promises";
2+
import fs, { type FileHandle } from "node:fs/promises";
33
import path from "node:path";
44
import { getResolvedLoggerSettings } from "../logging.js";
55
import { clamp } from "../utils.js";
@@ -26,6 +26,28 @@ function isRollingLogFile(file: string): boolean {
2626
return ROLLING_LOG_RE.test(path.basename(file));
2727
}
2828

29+
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
30+
export async function readLogWindowFully(
31+
handle: FileHandle,
32+
buffer: Buffer,
33+
position: number,
34+
): Promise<number> {
35+
let bytesRead = 0;
36+
while (bytesRead < buffer.length) {
37+
const result = await handle.read(
38+
buffer,
39+
bytesRead,
40+
buffer.length - bytesRead,
41+
position + bytesRead,
42+
);
43+
if (result.bytesRead === 0) {
44+
break;
45+
}
46+
bytesRead += result.bytesRead;
47+
}
48+
return bytesRead;
49+
}
50+
2951
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
3052
export async function resolveLogFile(file: string): Promise<string> {
3153
const stat = await fs.stat(file).catch(() => null);
@@ -126,8 +148,8 @@ async function readLogSlice(params: {
126148

127149
const length = Math.max(0, size - start);
128150
const buffer = Buffer.alloc(length);
129-
const readResult = await handle.read(buffer, 0, length, start);
130-
const text = buffer.toString("utf8", 0, readResult.bytesRead);
151+
const bytesRead = await readLogWindowFully(handle, buffer, start);
152+
const text = buffer.toString("utf8", 0, bytesRead);
131153
let lines = text.split("\n");
132154
if (start > 0 && prefix !== "\n") {
133155
// Drop the first partial line when starting in the middle of a file.

0 commit comments

Comments
 (0)