SIGN IN SIGN UP

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