Skip to content

[FS] Limit pipes to 64 KiB in all cases and use a single buffer - #28087

Draft
sbc100 wants to merge 1 commit into
emscripten-core:mainfrom
sbc100:pipefs-single-buffer
Draft

sbc100 wants to merge 1 commit into
emscripten-core:mainfrom
sbc100:pipefs-single-buffer

Conversation

@sbc100

@sbc100 sbc100 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

This change was written by Gemini under my supervision.

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: #27831

@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 simplifies the pipe implementation in libpipefs.js by replacing the bucket-based buffer mechanism with a single flat Uint8Array buffer of fixed capacity. This change removes the distinction between blocking and non-blocking writes, treating all writes as non-blocking and enforcing the pipe capacity uniformly. Consequently, related tests and changelog entries have been updated to reflect this simplified behavior. There are no review comments, so no feedback is provided.

@sbc100
sbc100 requested a review from guybedford October 27, 2026 21:54
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
@sbc100
sbc100 force-pushed the pipefs-single-buffer branch from b4bffe4 to 0bae681 Compare October 27, 2026 22:00

@guybedford guybedford left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the simplification, the single buffer is a clear win over the bucket list. Checked out locally; pipe, poll, select and epoll core tests pass along with the codesize baseline.

My main concern is that bounding blocking writers changes behaviour for a Linux-correct program: a blocking writer on one pthread with a reader on another previously worked (unbounded buffering), and with this change the write fails with EAGAIN once 64 KiB is queued:

// -pthread -sPROXY_TO_PTHREAD, reader thread draining p[0]
for (int i = 0; i < 64; i++)
  assert(write(p[1], chunk, 4096) == 4096); // fails at i == 16 with EAGAIN

Through stdio this is worse, since musl's __stdio_write turns the negative return into F_ERR, so an fwrite loop over a pipe permanently sets ferror after 64 KiB.

#28083 makes blocking writes from pthreads wait for the reader, which is compatible with this change (its fd_write path retries on EAGAIN for a blocking stream, so the PIPEFS condition there would just become unnecessary). The case this forecloses is the main thread without pthreads, where no wait is possible and unbounded buffering was the fallback. The read() symmetry argument cuts the other way for me: read() always returning EAGAIN is why PIPEFS cannot host a blocking reader, rather than a reason a blocking writer should also fail.

One way to keep both: keep the single buffer and the 64 KiB bound for O_NONBLOCK, and let a blocking writer grow the buffer past CAPACITY (or keep the bound only where fd_write can wait). A few smaller notes inline.

AI generated comment under my review

Comment thread src/lib/libpipefs.js
Comment thread src/lib/libpipefs.js
throw new FS.ErrnoError({{{ cDefs.EPIPE }}});
}

// Behave as if the write end is always non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the behaviour change for blocking writers described in the main comment. If it stays, the ChangeLog entry should say that blocking writes now behave as non-blocking, since the previous entry promised the opposite.

Comment thread src/lib/libpipefs.js
pipe.buckets.push(newBucket);
newBucket.buffer.set(data);
if (pipe.offset + dataLen > PIPEFS.CAPACITY) {
pipe.buffer.copyWithin(0, pipe.roffset, pipe.offset);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Near a full pipe every write compacts up to 64 KiB. 100k 512-byte read/write ping-pongs measured 83 ms here versus 47 ms on main. Not a blocker, a two-segment ring (read/write split at the wrap) would avoid it at similar code size.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm.. good point doing a lot of copyWithin is probably fairly costly.

@sbc100

sbc100 commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Now I'm not so sure this is worth landing.. maybe it is still?

Maybe we should at least wait until after #28083 lands?

@guybedford

Copy link
Copy Markdown
Collaborator

It still seems useful, but yes maybe just wait on #28083 here first?

@guybedford

Copy link
Copy Markdown
Collaborator

Correcting my earlier framing, since I had the model wrong. On Linux a blocking write that does not fit puts 64 KiB in the pipe and the thread sleeps holding the rest; the pipe itself never exceeds 64 KiB. #28083 gives a pthread writer exactly that by waiting on POLLOUT, so with #28083 landing first this change is correct wherever that wait applies (a proxied pthread call, outside ASYNCIFY builds).

The case left is a blocking writer that cannot sleep, most commonly the main thread, for example main producing into a pipe that a pthread drains. The buffering past 64 KiB that #28082 does today is that writer's parked remainder, not a larger pipe capacity. With the fixed buffer here the remainder has nowhere to go and the write returns a short count or EAGAIN where it previously completed.

So I'd keep the single 64 KiB buffer and add a parked-remainder queue beside it for writers that can neither take EAGAIN (non-blocking) nor wait (anything outside #28083's proxied, non-ASYNCIFY pthread case). write appends the remainder and returns the full count, and read refills the buffer from it before deciding whether to signal POLLOUT, so a waiting writer is not woken ahead of parked bytes. The capacity, PIPE_BUF rule and EAGAIN semantics stay exactly as in this PR, and the refactor is then observably unchanged. Rebasing on #28083 would let the pthread condition there collapse to that one check.

AI generated comment under my review

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.

3 participants