Skip to content

Commit 6e9fff8

Browse files
committed
πŸ› Bound a measuring render by the engine's text budget
The REPL's width measurement bounded each render by element count only. The engine has a second budget β€” every space-separated part of every text in a render is kept in a cache it refuses from, and that cache holds 16,384 of them however few elements carry them. Rebuilding the instance gives it the same cache, so this is the refusal the renderer's recovery cannot absorb, and it reached the screen as the error that ends it. A long Output did it two ways: one recorded line held more parts than any batch size bounds, and a batch inside the element budget still asked for more parts than the cache holds. Both now bound the request rather than the document β€” a batch is bounded by parts as well as elements, and a line is measured as far as the region could still hold rather than to its end. Measured on the retained record the reported crash came from: 9.84 MB, 4,029 lines, longest line 90,294 parts, now prepared whole as 120,650 rows with every logical line recovered exactly and no row wider than its region.
1 parent 3834f19 commit 6e9fff8

3 files changed

Lines changed: 183 additions & 7 deletions

File tree

β€Žarchitecture.mdβ€Ž

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7085,6 +7085,19 @@ Batch-local ids keep that set as small as one batch. This bounds the request,
70857085
not the document: nothing is truncated to make an engine accept it, and the
70867086
answers come back one per candidate in the order they were asked for.
70877087

7088+
**An engine bounds the text one render measures as well as its elements.** Every
7089+
space-separated part of every text in a render is kept in a cache the engine
7090+
refuses from, and that cache holds the same number of them however few elements
7091+
carry them β€” so a batch well inside the element budget still overruns it when
7092+
its rows are prose, and one line of a retained reading can hold more parts than
7093+
any render measures whatever a batch size is. A batch is therefore bounded by
7094+
both, and a line is measured as far as it could still fit its region rather than
7095+
to its end: every part after the first is preceded by a space, so a prefix of
7096+
more parts than the region has columns is already wider than the region, and
7097+
text does not narrow when more of it is appended. The cut that prefix bounds is
7098+
the reader's own line, measured as the row it became β€” a bounded question, not a
7099+
shortened document.
7100+
70887101
An engine that is asked for too much at once does not say so usefully. It
70897102
reports unbalanced structure for a list whose opens and closes are exactly
70907103
paired, which is not a capacity refusal β€” so it is not reclassified as one

β€Žpackages/cli/src/repl/fitting.tsβ€Ž

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,61 @@ const SEPARATOR = " ";
306306
*/
307307
const WIDTHS_PER_RENDER = 1024;
308308

309+
/**
310+
* How many space-separated parts one measuring render may carry.
311+
*
312+
* The element budget is not the engine's only one. Every part of every text a
313+
* render measures is kept in a cache the engine refuses from, and that cache
314+
* holds the same number of them however few elements carry them: measured on a
315+
* freshly built engine, one text of 16,384 parts and 1,366 texts of twelve each
316+
* refuse, while 16,383 parts go through either way. A tab and a newline are not
317+
* separators there, a run of spaces produces an empty part that costs what a
318+
* word costs, and two texts spelled the same share one entry.
319+
*
320+
* This is the budget whose refusal the renderer's recovery cannot absorb: a
321+
* replacement instance is given the same cache, so a render that overran it
322+
* overruns the replacement too and the refusal reaches the screen as the error
323+
* that ends it. Half the engine's own figure, which leaves the parts an
324+
* instance is still holding from the render before this one somewhere to sit.
325+
*/
326+
const WORDS_PER_RENDER = 8192;
327+
328+
/** What one text costs that budget, counted the way the engine counts it. */
329+
function wordsIn(text: string): number {
330+
let parts = 1;
331+
for (let at = 0; at < text.length; at += 1) {
332+
if (text[at] === " ") {
333+
parts += 1;
334+
}
335+
}
336+
return parts;
337+
}
338+
339+
/**
340+
* How much of one line could still fit a region, as a length in code units.
341+
*
342+
* A single text is bounded by no batch size, and one line of somebody's output
343+
* can hold more parts than a render measures β€” a journal of this product holds
344+
* lines of 19,999 β€” so a line is measured as far as it could still fit and
345+
* never further. That prefix settles the question it is asked: every part after
346+
* the first is preceded by a space, so `room + 2` of them are already wider
347+
* than `room` columns, and text does not narrow when more of it is appended.
348+
* The prefix ends at a space, which is a grapheme boundary of its own.
349+
*/
350+
function windowOf(text: string, room: number): number {
351+
const parts = Math.min(room + 2, WORDS_PER_RENDER);
352+
let seen = 0;
353+
for (let at = 0; at < text.length; at += 1) {
354+
if (text[at] === " ") {
355+
seen += 1;
356+
if (seen === parts) {
357+
return at;
358+
}
359+
}
360+
}
361+
return text.length;
362+
}
363+
309364
/**
310365
* Ask the engine how wide each of these texts is.
311366
*
@@ -324,12 +379,26 @@ function* widths(
324379
return Ok([]);
325380
}
326381
const answers: number[] = [];
327-
for (let from = 0; from < texts.length; from += WIDTHS_PER_RENDER) {
328-
const batch = yield* batchWidths(renderer, size, texts.slice(from, from + WIDTHS_PER_RENDER));
382+
let from = 0;
383+
while (from < texts.length) {
384+
let to = from;
385+
let words = 0;
386+
while (to < texts.length && to - from < WIDTHS_PER_RENDER) {
387+
const cost = wordsIn(texts[to]);
388+
// A text that outgrows the budget on its own still goes, alone, so the
389+
// engine's refusal names that text rather than this loop never advancing.
390+
if (to > from && words + cost > WORDS_PER_RENDER) {
391+
break;
392+
}
393+
words += cost;
394+
to += 1;
395+
}
396+
const batch = yield* batchWidths(renderer, size, texts.slice(from, to));
329397
if (!batch.ok) {
330398
return batch;
331399
}
332400
answers.push(...batch.value);
401+
from = to;
333402
}
334403
return Ok(answers);
335404
}
@@ -679,11 +748,15 @@ function* fitLine(
679748
if (line.text.length === 0) {
680749
return Ok([piece(0, 0, false, 0)]);
681750
}
682-
const whole = yield* widths(renderer, size, [line.text]);
683-
if (!whole.ok) {
684-
return whole;
751+
// Asked of as much of the line as the region could still hold. One row is a
752+
// line the engine measured as fitting, and a line held back from the question
753+
// is longer than the answer it would have given, so it is cut.
754+
const reach = windowOf(line.text, room);
755+
const reaching = yield* widths(renderer, size, [line.text.slice(0, reach)]);
756+
if (!reaching.ok) {
757+
return reaching;
685758
}
686-
if (whole.value[0] <= room) {
759+
if (reach === line.text.length && reaching.value[0] <= room) {
687760
return Ok([piece(0, line.text.length, false, 0)]);
688761
}
689762
// Where this line begins, so its continuations begin there too. Bounded well
@@ -728,7 +801,10 @@ function* cutAt(
728801
rest: string,
729802
room: number,
730803
): Operation<Result<number>> {
731-
const stops = graphemeStops(rest);
804+
// Over the boundaries of the prefix that could still fit, which is what keeps
805+
// every candidate inside one render's budget and keeps this from segmenting
806+
// the whole remainder once per row of a very long line.
807+
const stops = graphemeStops(rest.slice(0, windowOf(rest, room)));
732808
// Narrow by halves over grapheme boundaries, asking the engine for one batch
733809
// of candidates per round rather than one render per candidate.
734810
let low = 0;

β€Žpackages/cli/tests/repl-source-reading.test.tsβ€Ž

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,3 +1176,90 @@ describe("S6 a reading longer than one measuring render is still measured whole"
11761176
}));
11771177
}
11781178
});
1179+
1180+
/**
1181+
* S7 a reading with more words than one measuring render.
1182+
*
1183+
* The engine's element budget is not its only one. It keeps every
1184+
* space-separated part of every text a render measures in a cache it refuses
1185+
* from, and that cache holds 16,384 of them however few elements carry them β€”
1186+
* measured on a freshly built engine, 1,366 texts of twelve parts and one text
1187+
* of 16,384 both refuse. A refusal a bigger engine cannot fix is the one the
1188+
* renderer's recovery cannot absorb, so it reached the screen as the error that
1189+
* ends it.
1190+
*
1191+
* Both arms are the shape the reported crash had. An entry whose recorded
1192+
* output holds one line of 20,000 parts is the real one β€” a journal of this
1193+
* product holds lines of 19,999 β€” and it refused inside the first measurement
1194+
* of that single line, which no batch size bounds. A reading of 1,400 ordinary
1195+
* lines is the other half: each line measures on its own, and the pass that
1196+
* measures every finished row at once asked for more parts than the cache holds
1197+
* even though it asked for fewer elements than the element budget.
1198+
*/
1199+
/** One line of `count` space-separated parts, which is what the engine counts. */
1200+
function parts(count: number): string {
1201+
return Array.from({ length: count }, (_, at) => `${at % 10}`).join(" ");
1202+
}
1203+
1204+
describe("S7 a reading with more words than one measuring render is still measured whole", () => {
1205+
/** One recorded output line, as the reading an entry is shown as. */
1206+
function outputLinesOf(output: string): readonly ReplReadingLine[] {
1207+
return entryReading({
1208+
entry: entryOf('<Json value={{ a: 1 }} as="a" />', {
1209+
terminal: settled("ok", output),
1210+
settled: true,
1211+
}),
1212+
lifecycle: NO_LIFECYCLE,
1213+
live: "",
1214+
inspected: false,
1215+
}).output.lines;
1216+
}
1217+
1218+
it("recovers one line of 20,000 parts, exactly and in order", () =>
1219+
withRenderer(function* (renderer) {
1220+
const line = parts(20000);
1221+
const lines = outputLinesOf(line);
1222+
const prepared = yield* prepareReading(renderer, WIDE, lines, 100);
1223+
if (!prepared.ok) {
1224+
throw prepared.error;
1225+
}
1226+
1227+
// One line of somebody's output, whole: it was wrapped into many rows and
1228+
// nothing was shortened to make the engine accept it.
1229+
const whole = recovered(prepared.value);
1230+
expect(whole.size).toBe(lines.length);
1231+
for (const one of lines) {
1232+
expect([one.key, whole.get(one.key)]).toEqual([one.key, one.text]);
1233+
}
1234+
const rows = prepared.value.rows.filter((one) => one.line === lines[lines.length - 1].key);
1235+
expect(rows.length).toBeGreaterThan(1);
1236+
1237+
// And every row still fits the region it was cut for, which is the claim a
1238+
// width answered for a bounded prefix could break.
1239+
const { reservation } = prepared.value;
1240+
for (const row of prepared.value.rows) {
1241+
expect(row.width).toBeLessThanOrEqual(reservation.content - row.indent);
1242+
expect(runText(row.runs)).toBe(row.text);
1243+
}
1244+
}));
1245+
1246+
it("recovers 1,400 ordinary lines whose parts outgrow one render", () =>
1247+
withRenderer(function* (renderer) {
1248+
// Distinct lines, because the engine caches one entry per exact text: a
1249+
// thousand copies of one line cost it the parts of a single line and
1250+
// would prove nothing about what a batch of them asks for.
1251+
const lines = outputLinesOf(
1252+
Array.from({ length: 1400 }, (_, at) => `line ${at} ${parts(20)}`).join("\n"),
1253+
);
1254+
const prepared = yield* prepareReading(renderer, WIDE, lines, 100);
1255+
if (!prepared.ok) {
1256+
throw prepared.error;
1257+
}
1258+
1259+
const whole = recovered(prepared.value);
1260+
expect(whole.size).toBe(lines.length);
1261+
for (const one of lines) {
1262+
expect([one.key, whole.get(one.key)]).toEqual([one.key, one.text]);
1263+
}
1264+
}));
1265+
});

0 commit comments

Comments
Β (0)