Skip to content

[FS] Grow pipe buffers on demand as a ring - #28098

Open
PedroRosalba wants to merge 5 commits into
emscripten-core:mainfrom
PedroRosalba:pipefs-growable-ring
Open

PedroRosalba wants to merge 5 commits into
emscripten-core:mainfrom
PedroRosalba:pipefs-growable-ring

Conversation

@PedroRosalba

@PedroRosalba PedroRosalba commented Oct 29, 2026 •

Copy link
Copy Markdown

Follow-up to #28087, as discussed in #28087 (comment). This is stacked on #28087, so only the last commit is new here. I'll rebase once #28087 lands.

#28087 gives each pipe a single 64 KiB buffer, allocated when the pipe is created. Linux allocates pipe pages as data is written, so an empty pipe holds no data pages. The difference adds up when a program has many mostly-empty pipes. Rust's mio creates one waker pipe per Poll, which means one per tokio runtime, and each of those pipes holds a byte or two.

This change:

  • Starts each pipe with an 8 KiB buffer and doubles it, up to PIPEFS.CAPACITY, when a write needs more room.
  • Stores the data as a ring (head, count). A write near a full pipe wraps around instead of compacting with copyWithin. Growing copies the queued bytes, unwrapped, into the new buffer, at most three times per pipe.
  • Keeps the semantics from [FS] Limit pipes to 64 KiB in all cases and use a single buffer #28087: the capacity, the PIPE_BUF rule, poll readiness, EPIPE and EOF.

Not done: buffers don't shrink. A pipe that once held 64 KiB keeps that buffer until it is closed.

WasmFS is unchanged. Its pipes keep data in a std::queue<uint8_t>, which already grows with its contents.

Tests: test_pipe_capacity has a new section that sends bytes as a running counter, so data out of order fails an assert. The section wraps inside the initial buffer, grows while the data is wrapped, wraps the full-size buffer, and checks that a read doesn't write before its destination. Each of these breaks the test: removing the wrap in write(), removing the modulo on head in read(), and copying without unwrapping when growing. With 1000 pipes holding one byte each, the buffers total 65,536,000 bytes before this change and 8,192,000 after.

AI-generated with Claude, reviewed by me before posting.

sbc100 and others added 3 commits October 27, 2026 15:00
Since `PIPEFS` never blocks on `read()` or `write()` (already treating
`read()` as non-blocking regardless of `O_NONBLOCK`), apply the 64 KiB
pipe capacity limit to all writes in `libpipefs.js`.

With pipes bounded at 64 KiB in all cases, replace the multi-bucket
list in `libpipefs.js` with a single 64 KiB `Uint8Array` buffer that
resets its cursors when fully drained and lazily compacts unread bytes
with `copyWithin()` only when a write would run past the end of the
buffer.

See: emscripten-core#27831
A pipe allocated its full 64 KiB buffer up front. Programs that create
many mostly-empty pipes pay for all of it: Rust's mio creates one waker
pipe per Poll, so one per tokio runtime, and each holds a byte or two.

Start each pipe at 8 KiB and double up to the 64 KiB capacity as data
arrives, like Linux, which allocates pipe pages as they are written.
Store the data as a ring, so writes near a full pipe wrap around instead
of compacting with copyWithin. Growing unwraps into the new buffer, at
most three times per pipe.

1000 pipes holding one byte each now use 8,000 KiB of buffers instead of
64,000 KiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the pipe implementation in libpipefs.js from a bucket-based system to a dynamic ring buffer that starts small and doubles in size as needed, optimizing memory usage for mostly-empty pipes. It also updates the changelog and adds robust test coverage for buffer wrap-around and growth behaviors. The feedback suggests adding safety assertions in the new grow and copyOut helper functions to enforce capacity limits and prevent potential out-of-bounds writes.

Comment thread src/lib/libpipefs.js
Comment on lines +81 to +88
grow(pipe, needed) {
var size = pipe.buffer.length;
while (size < needed) size *= 2;
var buffer = new Uint8Array(size);
PIPEFS.copyOut(pipe, buffer, 0, pipe.count);
pipe.buffer = buffer;
pipe.head = 0;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential buffer overflows or unexpected growth beyond the maximum allowed capacity (PIPEFS.CAPACITY), we should add an assertion at the beginning of the grow function. This ensures that any future changes or unexpected callers of grow do not violate the capacity constraints.

    grow(pipe, needed) {
#if ASSERTIONS
      assert(needed <= PIPEFS.CAPACITY);
#endif
      var size = pipe.buffer.length;
      while (size < needed) size *= 2;
      var buffer = new Uint8Array(size);
      PIPEFS.copyOut(pipe, buffer, 0, pipe.count);
      pipe.buffer = buffer;
      pipe.head = 0;
    },

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bf37a3d.

AI-generated with Claude, reviewed by me before posting.

Comment thread src/lib/libpipefs.js
Comment on lines +91 to 97
copyOut(pipe, dest, offset, length) {
var first = Math.min(length, pipe.buffer.length - pipe.head);
dest.set(pipe.buffer.subarray(pipe.head, pipe.head + first), offset);
if (first < length) {
dest.set(pipe.buffer.subarray(0, length - first), offset + first);
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure safety and prevent potential out-of-bounds writes on the destination buffer, we should add an assertion in copyOut to verify that the destination buffer has sufficient capacity to hold the requested length of bytes starting at offset.

    copyOut(pipe, dest, offset, length) {
#if ASSERTIONS
      assert(offset + length <= dest.length);
#endif
      var first = Math.min(length, pipe.buffer.length - pipe.head);
      dest.set(pipe.buffer.subarray(pipe.head, pipe.head + first), offset);
      if (first < length) {
        dest.set(pipe.buffer.subarray(0, length - first), offset + first);
      }
    },

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypedArray.set already throws a RangeError when the source doesn't fit at offset, in every build, so an overflow here already fails loudly. The assertion wouldn't catch anything new.

AI-generated with Claude, reviewed by me before posting.

@sbc100

sbc100 commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Should we still wait until #28083 lands?

I'm happy to close #28087 and just make this into one single PR. Does that make sense to you too?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants