Skip to content

Commit 55a822d

Browse files
fix(sessions): keep tail follow cursor aligned with bytes actually read (#108127)
* fix(sessions): keep tail follow cursor aligned with bytes actually read * refactor(infra): share synchronous file window reads Co-authored-by: sunlit-deng <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 3b51889 commit 55a822d

3 files changed

Lines changed: 92 additions & 3 deletions

File tree

src/commands/sessions-tail.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,70 @@ describe("sessionsTailCommand", () => {
352352
expect(output).toContain("bash ok");
353353
});
354354

355+
it("delivers appended events across short follow reads without skipping bytes", async () => {
356+
const runtime = makeRuntime();
357+
await writeSessionEntry();
358+
await appendEvents([
359+
makeEvent({
360+
sourceSeq: 1,
361+
type: "session.started",
362+
ts: "2026-05-18T12:04:17.000Z",
363+
}),
364+
]);
365+
const appendedEvent = makeEvent({
366+
sourceSeq: 2,
367+
type: "tool.result",
368+
ts: "2026-05-18T12:04:21.000Z",
369+
data: { name: "python", success: true },
370+
});
371+
// POSIX positional reads may return fewer bytes than requested; cap each
372+
// call to prove the bounded delta is filled without skipping bytes.
373+
let capReads = false;
374+
let shortReadCalls = 0;
375+
const realReadSync = fs.readSync.bind(fs);
376+
const cappedReadSync = (
377+
fd: number,
378+
buffer: NodeJS.ArrayBufferView,
379+
offset: number,
380+
length: number,
381+
position: fs.ReadPosition | null,
382+
): number => {
383+
const cappedLength = capReads ? Math.min(length, 16) : length;
384+
if (capReads) {
385+
shortReadCalls += 1;
386+
}
387+
return realReadSync(fd, buffer, offset, cappedLength, position);
388+
};
389+
const readSpy = vi
390+
.spyOn(fs, "readSync")
391+
.mockImplementation(cappedReadSync as typeof fs.readSync);
392+
let appended = false;
393+
vi.mocked(runtime.log).mockImplementation((message) => {
394+
if (!appended && String(message).includes("session.started")) {
395+
appended = true;
396+
capReads = true;
397+
appendJsonl(trajectoryPath, appendedEvent);
398+
}
399+
});
400+
401+
const run = sessionsTailCommand(
402+
{ store: storePath, sessionKey, tail: "1", follow: true },
403+
runtime,
404+
);
405+
try {
406+
await waitForRuntimeOutput(runtime, "python ok");
407+
} finally {
408+
readSpy.mockRestore();
409+
process.emit("SIGTERM", "SIGTERM");
410+
await run;
411+
}
412+
413+
const output = runtimeOutput(runtime);
414+
expect(shortReadCalls).toBeGreaterThan(1);
415+
expect(output).toContain("tool.result");
416+
expect(output).toContain("python ok");
417+
});
418+
355419
it("continues following when later trajectory events are appended", async () => {
356420
const runtime = makeRuntime();
357421
await writeSessionEntry();

src/commands/sessions-tail.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.j
1616
import type { SessionEntry } from "../config/sessions/types.js";
1717
import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js";
1818
import { formatErrorMessage } from "../infra/errors.js";
19+
import { readFileWindowFullySync } from "../infra/file-read.js";
1920
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
2021
import { readRegularFileSync } from "../infra/regular-file.js";
2122
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
@@ -544,10 +545,10 @@ function readNewFileFollowEvents(state: FileFollowState): TrajectoryEvent[] {
544545
);
545546
}
546547
const buffer = Buffer.alloc(deltaBytes);
547-
fs.readSync(fd, buffer, 0, buffer.length, state.offset);
548-
state.offset = fileState.size;
548+
const bytesRead = readFileWindowFullySync(fd, buffer, state.offset);
549+
state.offset += bytesRead;
549550
state.fileState = fileState;
550-
const combined = `${state.pending}${state.decoder.write(buffer)}`;
551+
const combined = `${state.pending}${state.decoder.write(buffer.subarray(0, bytesRead))}`;
551552
// Keep an incomplete trailing JSON line until the next poll, matching
552553
// append-only writers that flush in chunks.
553554
const lines = combined.split(/\r?\n/u);

src/infra/file-read.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import type { FileHandle } from "node:fs/promises";
23

34
/** Fills a bounded positional-read buffer unless the file reaches EOF. */
@@ -21,3 +22,26 @@ export async function readFileWindowFully(
2122
}
2223
return bytesRead;
2324
}
25+
26+
/** Synchronously fills a bounded positional-read buffer unless the file reaches EOF. */
27+
export function readFileWindowFullySync(
28+
fd: number,
29+
buffer: Buffer,
30+
position: number,
31+
): number {
32+
let bytesRead = 0;
33+
while (bytesRead < buffer.length) {
34+
const count = fs.readSync(
35+
fd,
36+
buffer,
37+
bytesRead,
38+
buffer.length - bytesRead,
39+
position + bytesRead,
40+
);
41+
if (count === 0) {
42+
break;
43+
}
44+
bytesRead += count;
45+
}
46+
return bytesRead;
47+
}

0 commit comments

Comments
 (0)