Skip to content

Commit fe5295b

Browse files
committed
fix(sessions): complete tail-read windows despite short positional reads
Replace single-shot handle.read() calls in two session tail-read paths with readFileWindowFully so multibyte-range positional reads do not silently return incomplete tail data on network filesystems. - readRecentTranscriptTailLinesAsync: tail-window read for recent session messages now loops until the requested window fills - readLastMessagePreviewFromOpenTranscriptAsync: last-message preview tail read now loops until the requested window fills The same readFileWindowFully helper was introduced in #108253 and expanded with a sync variant in #108127 (both by sunlit-deng).
1 parent 862f915 commit fe5295b

2 files changed

Lines changed: 87 additions & 2 deletions

File tree

src/gateway/session-utils.fs.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,4 +2036,88 @@ describe("oversized transcript line guards", () => {
20362036
expect(asyncResult.lastMessagePreview).toBe("Bot says hello");
20372037
});
20382038
});
2039+
2040+
describe("short read resilience", () => {
2041+
let tmpDir: string;
2042+
let storePath: string;
2043+
2044+
registerTempSessionStore("openclaw-short-read-test-", (nextTmpDir, nextStorePath) => {
2045+
tmpDir = nextTmpDir;
2046+
storePath = nextStorePath;
2047+
});
2048+
2049+
function installShortReadProxy(maxPerCall = 16) {
2050+
const realOpen = fs.promises.open.bind(fs.promises);
2051+
return vi.spyOn(fs.promises, "open").mockImplementation(async (...args: unknown[]) => {
2052+
const handle = await realOpen(...(args as Parameters<typeof realOpen>));
2053+
const realRead = handle.read.bind(handle);
2054+
return new Proxy(handle, {
2055+
get(target, prop, receiver) {
2056+
if (prop !== "read") return Reflect.get(target, prop, receiver);
2057+
return (buf: Buffer, offset: number, length: number, position: number | null) =>
2058+
realRead(buf, offset, Math.min(length, maxPerCall), position);
2059+
},
2060+
});
2061+
});
2062+
}
2063+
2064+
test("readRecentSessionMessagesAsync survives 16-byte tail read caps", async () => {
2065+
const sessionId = "test-short-read-recent";
2066+
const lines = [
2067+
{ type: "session", version: 1, id: sessionId },
2068+
...Array.from({ length: 30 }, (_, i) => ({
2069+
message: {
2070+
role: i % 2 ? "assistant" : "user",
2071+
content: `message ${i}: ${"data ".repeat(40)}`,
2072+
},
2073+
})),
2074+
];
2075+
writeTranscript(tmpDir, sessionId, lines);
2076+
2077+
installShortReadProxy(16);
2078+
try {
2079+
const result = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
2080+
maxMessages: 20,
2081+
maxBytes: 8192,
2082+
});
2083+
expect(result.length).toBeGreaterThanOrEqual(5);
2084+
for (const msg of result) {
2085+
const content = (msg as Record<string, unknown>).content as string;
2086+
expect(content).toBeTruthy();
2087+
}
2088+
} finally {
2089+
vi.restoreAllMocks();
2090+
}
2091+
});
2092+
2093+
test("readRecentSessionMessagesAsync honors maxBytes under short reads", async () => {
2094+
const sessionId = "test-short-read-byte-cap";
2095+
const lines = [
2096+
{ type: "session", version: 1, id: sessionId },
2097+
...Array.from({ length: 20 }, (_, i) => ({
2098+
message: {
2099+
role: i % 2 ? "assistant" : "user",
2100+
content: `line ${String(i).padStart(2, "0")}: ${"payload ".repeat(30)}`,
2101+
},
2102+
})),
2103+
];
2104+
writeTranscript(tmpDir, sessionId, lines);
2105+
2106+
const normal = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
2107+
maxMessages: 20,
2108+
maxBytes: 4096,
2109+
});
2110+
2111+
installShortReadProxy(64);
2112+
try {
2113+
const short = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
2114+
maxMessages: 20,
2115+
maxBytes: 4096,
2116+
});
2117+
expect(Math.abs(short.length - normal.length)).toBeLessThanOrEqual(1);
2118+
} finally {
2119+
vi.restoreAllMocks();
2120+
}
2121+
});
2122+
});
20392123
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

src/gateway/session-utils.fs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
scanSessionTranscriptTree,
2020
selectSessionTranscriptTreePathNodes,
2121
} from "../config/sessions/transcript-tree.js";
22+
import { readFileWindowFully } from "../infra/file-read.js";
2223
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
2324
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
2425
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
@@ -255,7 +256,7 @@ async function readRecentTranscriptTailLinesAsync(
255256
const handle = await fs.promises.open(filePath, "r");
256257
try {
257258
const buffer = Buffer.alloc(readLen);
258-
const { bytesRead } = await handle.read(buffer, 0, readLen, readStart);
259+
const bytesRead = await readFileWindowFully(handle, buffer, readStart);
259260
if (bytesRead <= 0) {
260261
return [];
261262
}
@@ -1188,7 +1189,7 @@ async function readLastMessagePreviewFromOpenTranscriptAsync(params: {
11881189
const readStart = Math.max(0, params.size - LAST_MSG_MAX_BYTES);
11891190
const readLen = Math.min(params.size, LAST_MSG_MAX_BYTES);
11901191
const buffer = Buffer.alloc(readLen);
1191-
const { bytesRead } = await params.handle.read(buffer, 0, readLen, readStart);
1192+
const bytesRead = await readFileWindowFully(params.handle, buffer, readStart);
11921193
if (bytesRead <= 0) {
11931194
return null;
11941195
}

0 commit comments

Comments
 (0)