fix(ls): bound directory entry collections before per-entry work (#390)
* fix(ls): bound directory entry collections before per-entry work (#340) `ls` eagerly awaited `ctx.fs.readdir()`, sorted the whole result, statted every entry for -l/-S/-F/-R, and only enforced a limit once formatted output was appended. A filesystem backend fronting a very large directory could therefore exhaust the embedding process's memory, and piping to `head` did not bound the work because pipeline producers are materialized before consumers run. Admit each freshly read batch of entries into the command's budgets at the point it enters `ls`, before any sorting, statting, classifying or formatting: - `checkEntryCount` rejects a collection larger than `maxArrayElements` with a controlled `ExecutionLimitError` (exit code 126). - `admitEntries` additionally reserves the batch against the shared `FileTraversalBudget`, so a recursive listing cannot bypass the per-directory bound by walking many small directories. - The recursive `readdirWithFileTypes` re-read is bounded without double-reserving children already admitted from the plain `readdir`. Also replaces an O(n^2) `filteredEntries.includes()` scan in the recursive path with a `Set` lookup, since that scan runs once per entry over the newly bounded - but still large - entry list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net> * fix(ls): close budget gaps found in review of #340 Three follow-ups from the automated review panel, each reproduced first: - Multi-operand listings undercounted. `visit()` and `discover()` keep separate counters, so charging an operand as a visit does not reserve it as a root whose children are about to be discovered. `ls /a /b` traversed six entries under a `maxTraversalEntries: 5` budget and exited 0. Every directory operand past the first is now reserved against the same counter its children are charged to. - `-a` overshot the array bound. A directory holding exactly `maxArrayElements` entries passed the check, then had "." and ".." prepended for a listing two elements over the limit. The synthetic entries are charged up front. - Recursive descent multiplied the bound by the batch width. Up to `DEFAULT_BATCH_SIZE` (100) child `listPath` calls ran concurrently and each awaited `readdir()` before `admitEntries` reserved anything, so a wide tree materialized every sibling's entry list before the shared budget rejected the first. Measured: 20 sibling directories materialized 200 entries against a budget of 30. Subdirectories are now descended one at a time; entries within a single directory are still statted in parallel batches, since that work runs over an already-admitted list. Output ordering is unchanged - results were already sorted by rank. Splits `FileTraversalBudget.discover()` into `reserve()`, which charges the entry ceiling only, and `discover()`, which reserves and also charges the work. `ls` admits entries with `reserve()` because reading a directory is one filesystem operation however many names come back; that keeps a plain name-order listing as cheap in `maxTraversalWork` as #363 established. `find` keeps `discover()`, where every discovered child does become a work item that is later visited. Adds a changeset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net> --------- Signed-off-by: Lars Trieloff <lars@trieloff.net> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
L
Lars Trieloff committed
b7f556fcbedc21ee4e346ad858f29c4d7d35a49e
Parent: 556a739
Committed by GitHub <noreply@github.com>
on 9/7/2026, 7:37:40 PM