Skip to content

Commit 2426e5c

Browse files
committed
fix(read-file): window scan-capped lines so no tail is silently lost
A single line past the 8MB scan ceiling was truncated to 2000 chars under a continuation footer whose offset skipped the unshown middle. The scan-capped remainder is now windowed like smaller overlong lines, so every scanned byte reassembles through offset continuation.
1 parent 733b665 commit 2426e5c

2 files changed

Lines changed: 53 additions & 14 deletions

File tree

‎src/plugins/read-file-guard-plugin.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe("readFileBounded", () => {
165165
expect(content).not.toContain("continue");
166166
});
167167

168-
test("a newline-less file past the scan ceiling returns content, not empty", async () => {
168+
test("a newline-less file past the scan ceiling returns a windowed page, not empty", async () => {
169169
const giant = "a".repeat(READ_FILE_MAX_SCAN_BYTES + 1024);
170170
const p = await fixture("giant-line.txt", giant);
171171
const { content, isError } = await readFileBounded(
@@ -177,7 +177,21 @@ describe("readFileBounded", () => {
177177
expect(isError).toBeUndefined();
178178
expect(content.length).toBeGreaterThan(0);
179179
expect(content).toContain(" 1\t");
180-
expect(content).toContain("scan limit");
180+
// The scan-capped remainder is windowed like a smaller overlong line, so
181+
// the footer offers a deliverable offset page instead of a truncated line
182+
// under a scan notice whose tail is unreachable.
183+
expect(content).not.toContain("line truncated");
184+
expect(content).not.toContain("scan limit");
185+
expect(content).toContain("output limit");
186+
expect(content).toContain("Use offset=");
187+
const body = content.split("\n\n")[0] ?? "";
188+
const numbered = body.trimEnd().split("\n");
189+
expect(numbered.length).toBeGreaterThan(1);
190+
for (const line of numbered) {
191+
expect(line.replace(/^\s*\d+\t/, "").length).toBeLessThanOrEqual(
192+
READ_FILE_MAX_LINE_LENGTH,
193+
);
194+
}
181195
});
182196

183197
test("abort rejects with read_file timeout guidance", async () => {
@@ -446,6 +460,37 @@ describe("CL-8979 large-file pagination", () => {
446460
expect(collected).toContain("END");
447461
});
448462

463+
test("windows a single file line past the scan ceiling with exact reassembly", async () => {
464+
const filler = "0123456789ABCDEF".repeat(
465+
Math.ceil((READ_FILE_MAX_SCAN_BYTES + 4096) / 16),
466+
);
467+
const payload = `HEAD-${filler}-TAIL`;
468+
expect(payload.length).toBeGreaterThan(READ_FILE_MAX_SCAN_BYTES);
469+
const p = await fixture("cl8979-scan-giant.txt", `${payload}\nEND\n`);
470+
const rows: string[] = [];
471+
let offset = 0;
472+
let hops = 0;
473+
for (;;) {
474+
const res = await readFileBounded(p, offset, 2000, neverAbort());
475+
hops += 1;
476+
expect(res.isError).toBeUndefined();
477+
const content = String(res.content);
478+
// No silent tail loss: every scanned byte is windowed, never truncated,
479+
// and no footer promises continuation it cannot deliver.
480+
expect(content).not.toContain("line truncated");
481+
expect(content).not.toContain("scan limit");
482+
rows.push(...bodyRows(content));
483+
const next = continueOffset(content);
484+
if (next === null) break;
485+
expect(next).toBeGreaterThan(offset);
486+
offset = next;
487+
expect(hops).toBeLessThan(500);
488+
}
489+
expect(hops).toBeGreaterThan(1);
490+
expect(rows[rows.length - 1]).toBe("END");
491+
expect(rows.slice(0, -1).join("")).toBe(payload);
492+
}, 180_000);
493+
449494
test("a large-file page passes the result-truncation layer byte-identical", async () => {
450495
const name = "cl8979-page.txt";
451496
const rows = Array.from(

‎src/plugins/read-file-guard-plugin.ts‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,6 @@ function readStreamBounded(
125125
let truncReason: TruncReason | undefined;
126126
let endReached = false;
127127
let settled = false;
128-
// Set when the scan ceiling trips: the trailing partial is reported
129-
// truncated (never windowed), so a line longer than one scan pass keeps
130-
// the scan-limit notice instead of a byte-limit page.
131-
let scanCapped = false;
132128

133129
const out: string[] = [];
134130

@@ -198,13 +194,10 @@ function readStreamBounded(
198194
if (pending.length > READ_FILE_MAX_LINE_LENGTH && !windowHugeLines) {
199195
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
200196
pendingOverflow = true;
201-
} else if (
202-
windowHugeLines &&
203-
pending.length > READ_FILE_MAX_SCAN_BYTES
204-
) {
205-
pending = pending.slice(0, READ_FILE_MAX_LINE_LENGTH);
206-
pendingOverflow = true;
207197
}
198+
// windowHugeLines keeps the full pending: the scan trip ends the
199+
// stream, and flushRemainder windows the remainder so every scanned
200+
// byte stays reachable through offset continuation.
208201
return true;
209202
}
210203
const line = pending.slice(0, nl);
@@ -227,9 +220,11 @@ function readStreamBounded(
227220
emitWrapped(pending, true);
228221
return;
229222
}
223+
// Windowed even when scan-capped: every window burns a line number,
224+
// so the footer's offset resumes at the next window instead of promising
225+
// continuation that skips the unshown middle of an overlong line.
230226
if (
231227
!pendingOverflow &&
232-
!scanCapped &&
233228
windowHugeLines &&
234229
pending.length > contentBudget
235230
) {
@@ -290,7 +285,6 @@ function readStreamBounded(
290285
return;
291286
}
292287
if (scanned >= READ_FILE_MAX_SCAN_BYTES) {
293-
scanCapped = true;
294288
flushRemainder();
295289
if (truncReason === undefined) truncReason = "scan";
296290
finishOk();

0 commit comments

Comments
 (0)