Skip to content

Surface IPC end-of-stream on win32 - #92

Closed
bavulapati wants to merge 1 commit into
mainfrom
fix/win32-ipc-eof
Closed

bavulapati wants to merge 1 commit into
mainfrom
fix/win32-ipc-eof

Conversation

@bavulapati

Copy link
Copy Markdown
Contributor

Follow-up to #88 / #83, closing the win32 gap tracked in #91.

#88 makes a worklet close Bare.IPC on exit so the host reads EOF and can detect a stopped worklet. That worked on the posix IPC path but not win32, whose IPC layer had no end-of-stream support: bare_ipc_read only drained a buffer (never returned EOF), and bare_ipc__on_read asserted nread >= 0, so a real UV_EOF would abort. #88 scoped the EOF tests off win32 as a stopgap.

This adds the missing EOF path:

  • bare_ipc__on_read: on UV_EOF, mark an eof flag, stop reading, and fire the readable poll so the consumer gets notified.
  • bare_ipc_read: return a zero-length read (len 0) once EOF is reached and the buffer is drained.
  • worklet.c: flush the loop (uv_run(&loop, UV_RUN_NOWAIT)) after bare_run returns - a no-op where uv_close is synchronous (posix), but required on win32 so the worklet's own IPC close completes before it parks.
  • Rewrote worklet-exit-eof / worklet-jsend-eof to be poll-based (the portable read model) and re-enabled them on win32.

Validation: posix locally (darwin, full suite 13/13, EOF tests 5/5 stable); win32 via the CI Test / win32-* jobs, which now run the re-enabled EOF tests on real Windows.

The win32 IPC layer is yours by design - happy to adjust the shape (e.g. how EOF is surfaced through the poll) if you'd prefer it done differently.

The win32 IPC layer had no EOF path: bare_ipc_read never returned end-of-stream
and bare_ipc__on_read asserted nread >= 0, so a worklet closing its IPC on exit
could not be observed by the host (and risked aborting on UV_EOF). Mark EOF in
on_read, notify the readable poll, and return a zero-length read from
bare_ipc_read. Flush the worklet loop after run so the close completes on
Windows, and make the EOF tests poll-based so they run on all platforms.

Closes #91.
@bavulapati
bavulapati requested a review from a team June 30, 2026 08:30
Comment thread shared/worklet.c
// and the host sees EOF before we park waiting for teardown. This is a no-op
// where uv_close is synchronous (posix) but is required on platforms where
// the close only completes via the loop (win32).
uv_run(&loop, UV_RUN_NOWAIT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, dang; ipc.destroy() is of course asynchronous and scheduling additional asynchronous work from an exit handler is strictly forbidden and leads to issues like this. We need to do something different.

@bavulapati

Copy link
Copy Markdown
Contributor Author

Agreed the root cause is closing Bare.IPC from the exit handler: ipc.destroy() schedules an async uv_close the runtime is already done servicing, and a pump (in bare-kit or in bare) only papers over that. Dropping the pump idea in both places.

The graceful path has a clean fix that keeps the close inside the live loop: move it from exit to beforeExit. bare_runtime_run is a do/while that fires beforeExit and then re-checks uv_loop_alive, so a uv_close issued from beforeExit keeps the loop alive for one more turn and flushes before exit fires and before bare_run returns. No pump, no async-from-exit:

Bare.on('suspend', () => ipc.unref())
  .on('resume', () => ipc.ref())
  .on('beforeExit', () => ipc.destroy()) // was 'exit'

That covers loop-drain exit and the host-initiated terminate case (the host already knows it stopped, and bare stays alive for bare_worklet_terminate before destroy).

The one case it does not cover is a worklet that calls Bare.exit() itself. That goes through the terminated state, so bare_runtime_run breaks past beforeExit straight to exit, IPC never closes, and the host never sees EOF. This is exactly what worklet-exit-eof asserts.

I looked for a way to close that gap in bare-kit without reintroducing async-from-exit or a pump, and did not find a clean one:

  • teardown-before-park: teardown does pump and would flush EOF, but freeing bare before the park is a use-after-free. Both hosts call bare_worklet_terminate (which derefs worklet->bare) then bare_worklet_destroy, and the park is what keeps bare alive for that sequence. Making it safe means guarding and locking every host entry point against teardown.
  • synchronous fd close from C: the write end is a live uv_pipe owned by JS, so a raw close() races teardown's uv_close on the same fd.

So the residual question is only about the Bare.exit() path: how would you want a self-exiting worklet to signal EOF? The options I see all touch runtime semantics, so I do not want to pick one unilaterally:

  1. A finish signal on the terminate path (a narrower form of Add bare_worklet_on_exit to detect a self-exiting worklet #85), used only where beforeExit cannot fire.
  2. Have bare flush closing handles on the terminate path only, not the general post-exit drain from Drain closing handles after the exit event bare#160 that you rejected.
  3. Something else you have in mind.

Happy to implement whichever shape you prefer. I will close holepunchto/bare#160 in favor of the bare-kit fix.

@kasperisager

Copy link
Copy Markdown
Contributor

The crux of the issue seems to be that the ownership is muddy. Bare Kit should really just close() the IPC descriptors before blocking for termination and then we should find a way of making that possible without tripping up libuv. It may well be that the descriptors should be allocated and owned by the native side instead of having the JavaScript side do IPC.open().

@bavulapati

Copy link
Copy Markdown
Contributor Author

Picking up your point about ownership, I sketched where it leads so we can shape it together before committing to code.

The direction: native allocates and owns the IPC descriptors instead of JS IPC.open(). If native solely owns the worklet's outgoing end, it can close() it synchronously right after bare_run returns and before the thread parks on the finished semaphore, which surfaces EOF to the host on every exit path (loop drain, Bare.exit(), and host terminate) with no async work scheduled from exit, no loop pump, and no libuv patch. The JS exit handler doing ipc.destroy() then goes away entirely. That reads like what you were pointing at, but tell me if I've drifted from it.

One design choice I'd like your take on: native being the sole closer means JS can't hold a uv_pipe on that descriptor, so the outgoing writes move through native. Incoming can stay a JS uv_pipe, since only the write end needs native ownership for EOF, so we'd only be moving the outgoing half rather than the whole IPC. Do you prefer keeping it that narrow, or making both ends native and symmetric with the host-side bare_ipc? I can see it either way and don't have a strong preference.

Where I'd really like to pair is the platform split. posix looks straightforward: native holds the raw fd, writes with uv_poll for backpressure, and close()s at exit. win32 is the meatier part, since named pipes aren't uv_poll-able and a uv_pipe can't be closed synchronously, so a native sole-owned async write end there lands in overlapped/IOCP named-pipe I/O. Since that layer is yours and I've only got CI for Windows, I'd rather we work that part out together than have me guess at it.

A way we could split it, if it suits you: I take the shared + JS + posix end and get the exit-EOF suite green on darwin, and we sort out the win32 write path between us (however you'd like to divide that). Worth flagging that posix already reaches EOF today, so that half is mostly a refactor that removes the forbidden pattern; the behavior that actually goes green is win32.

One caveat that holds regardless of how we shape it: on a hard Bare.exit()/terminate the loop stops mid-flight, so any unflushed buffered outgoing writes are lost. That's inherent to the exit, not something this introduces, but wanted it on the table.

If this tracks for you, I'll get started on the posix end and open a PR we can iterate on. I'll also close holepunchto/bare#160.

@kasperisager

Copy link
Copy Markdown
Contributor

I had blissfully forgotten about the whole Windows socket debacle 🤦 That's not a path we want to go down, it only leads to pain.

We probably need to accept that we're using the wrong tool for the job. IPC over pipes and sockets works really well when you couple if with FD_CLOEXEC across a process boundary, but Bare Kit is entirely in-process. We should move to a simple in-process queue instead, just like the one https://github.com/holepunchto/bare-channel provides. Then the only platform specific concern is signalling, which is a much easier problem to solve.

@bavulapati

Copy link
Copy Markdown
Contributor Author

Agreed, in-process queue is the right call; it turns the hard part into just signalling.

On the primitive: bare-channel assumes both ends are JS (its port needs a js_env), and our host end has no JS runtime. libpearsync fits our exact asymmetry, message passing between a uv/JS thread and a plain native thread, where signalling is a callback you provide. It is already doing this in keet-mobile-core (native host driving a Bare core over pearsync_open_thread), so it is proven for our shape.

I would pull it into bare-kit as a proper fetched dependency rather than copying loose files, which needs a small CMakeLists.txt upstream so it is consumable like bare. Does libpearsync match what you had in mind? Happy to go deeper on the bare-kit shape once we are aligned on the primitive.

@kasperisager

Copy link
Copy Markdown
Contributor

No external dependencies for this, it's simple enough for the entire implementation to live in Bare Kit.

@bavulapati

Copy link
Copy Markdown
Contributor Author

Superseded by #94, which fixes this on the queue transport rather than patching EOF onto the pipes. Closing.

@bavulapati bavulapati closed this Aug 13, 2026
bavulapati added a commit that referenced this pull request Aug 13, 2026
Bare Kit is in-process (worklets are threads), so the pipe-based IPC with its
async close-on-exit was the wrong tool and the root of the EOF and win32
problems (holepunchto/bare#160, #92). Move the transport onto the in-process
queue primitive:

- Bare.IPC becomes a bare-stream Duplex over native queue ops that worklet.c
  injects (read/write/close/ref/unref); end() closes the queue so the host
  reaches EOF.
- The host bare_ipc runs over the queue's thread port; the apple poll is a
  manual dispatch source fired by the queue signal, reporting readiness via
  bare_queue_readable/writable.
- The worklet closes its queue end synchronously after bare_run, so the host
  reaches EOF on every exit path (loop drain, Bare.exit, IPC.end, terminate)
  with no loop pump and no async scheduled from exit.
- bare_worklet_destroy joins the worklet thread (terminating and waking it
  first) so the runtime and queue finish tearing down before the host returns.
- Delete the pipe posix/ipc.c.

Darwin only so far: android/linux/win32 polls are not yet ported. All 14 darwin
worklet and queue tests pass.
bavulapati added a commit that referenced this pull request Aug 14, 2026
Bare Kit is in-process - worklets are threads - so the pipe transport, with its
async close on exit, was the wrong tool and the root of the EOF and win32
problems (holepunchto/bare#160, #92).

Bare.IPC becomes a bare-stream Duplex over the queue, and the worklet closes its
end synchronously after bare_run, so the host reaches EOF on every exit path with
no loop pump and nothing scheduled from the exit event. The host polls readiness
rather than reading a pipe: apple keeps its dispatch source, linux and win32 now
share one uv implementation instead of a hand-rolled epoll thread and a pipe
loop, and android keeps its looper with the ipc owning the eventfd.

bare_ipc_get_incoming and bare_ipc_get_outgoing go with the pipes; the win32
exports follow. The two EOF tests now run on win32, which is what this was for.
bavulapati added a commit that referenced this pull request Aug 14, 2026
Bare Kit is in-process - worklets are threads - so the pipe transport, with its
async close on exit, was the wrong tool and the root of the EOF and win32
problems (holepunchto/bare#160, #92).

Bare.IPC becomes a bare-stream Duplex over the queue, and the worklet closes its
end synchronously after bare_run, so the host reaches EOF on every exit path with
no loop pump and nothing scheduled from the exit event. The host polls readiness
rather than reading a pipe: apple keeps its dispatch source, linux and win32 now
share one uv implementation instead of a hand-rolled epoll thread and a pipe
loop, and android keeps its looper with the ipc owning the eventfd.

The two poll tests closed their loop while their poll was still armed. Pipes hid
that: their EOF arrived asynchronously at thread teardown, while the queue closes
synchronously, so the wake lands in the window and the callback posts to a closed
loop - a segfault on linux-x64. The poll is retired first now.

bare_ipc_get_incoming and bare_ipc_get_outgoing go with the pipes; the win32
exports follow. Both EOF tests now run on win32, which is what this was for.
bavulapati added a commit that referenced this pull request Aug 14, 2026
Bare Kit is in-process - worklets are threads - so the pipe transport, with its
async close on exit, was the wrong tool and the root of the EOF and win32
problems (holepunchto/bare#160, #92).

Bare.IPC becomes a bare-stream Duplex over the queue, and the worklet closes its
end synchronously after bare_run, so the host reaches EOF on every exit path with
no loop pump and nothing scheduled from the exit event. The host polls readiness
rather than reading a pipe: apple keeps its dispatch source, linux and win32 now
share one uv implementation instead of a hand-rolled epoll thread and a pipe
loop, and android keeps its looper with the ipc owning the eventfd.

The two poll tests closed their loop while their poll was still armed. Pipes hid
that: their EOF arrived asynchronously at thread teardown, while the queue closes
synchronously, so the wake lands in the window and the callback posts to a closed
loop - a segfault on linux-x64. The poll is retired first now.

bare_ipc_get_incoming and bare_ipc_get_outgoing go with the pipes; the win32
exports follow. Both EOF tests now run on win32, which is what this was for.
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