Skip to content

std.ipc: two-way socket channel with descriptor passing - #3490

Merged
paul-hammant merged 4 commits into
mainfrom
feat/ipc-socket-fd-passing
Oct 31, 2026
Merged

paul-hammant merged 4 commits into
mainfrom
feat/ipc-socket-fd-passing

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

std.ipc goes from a one-way child-to-parent pipe to a two-way socket channel that can also pass open file descriptors, on Linux, FreeBSD and macOS. It is the transport for running untrusted code in a locked-down child that cannot open files itself: the parent checks a request, opens the file, and passes the descriptor across. Message framing (CBOR) and the higher layers are later work.

Jules drafted it on the fork (paul-hammant#46, merged here unchanged); the second commit reshapes it.

API

  • os.run_socket(prog, argv, env) -> (fd, pid, err): like run_pipe, through the same spawn path, but the channel is one end of an AF_UNIX stream pair at fd 3, with AETHER_IPC_FD=3 and AETHER_IPC_KIND=socket.
  • os.socketpair() -> (a, b, err): a pair within one process. Both ends close-on-exec.
  • ipc.parent_channel_kind(): "pipe", "socket" or "".
  • ipc.read(ch, max): one binary-safe read; ("", "") at end of stream.
  • ipc.send_fd(ch, fd, payload) / ipc.recv_fd(ch, max).
  • ipc.write now writes every byte itself rather than through std.net.fd_write.

parent_channel(), write_close, os.run_pipe and os.run_pipe_drain_and_wait are unchanged (skirae and aeb use them), and std_ipc_roundtrip / std_ipc_bash_chain pass as they were.

Under --emit=lib, std.ipc now sits under --with=os. Before, it was only rejected because it imported std.net, so it needed --with=net. It now imports std.io for fd_close, so os is the only capability it needs.

What the receiver can rely on, and the test that fails without each

Rule Protection removed Caught by
1. Exactly one descriptor per message; a refused message's descriptors are closed the close on refusal integration: descriptor-table count +2, then +10
the more-than-one check integration: two descriptors accepted
the MSG_CTRUNC check integration: ten descriptors misreported
2. Close-on-exec at receipt MSG_CMSG_CLOEXEC spec: a child inherits the received descriptor
SOCK_CLOEXEC on the pair spec: a child inherits both ends
3. No SIGPIPE MSG_NOSIGNAL spec: the process dies with signal 13
4. Whole writes a short send counted as whole C test ipc_write_finishes_sends_signals_cut_short
EINTR treated as an error the same C test
5. A message without a descriptor is an error the no-descriptor check spec: two cases
--emit=lib gate the std.ipc → os entry emit_lib_banned: std.ipc accepted

Rule 4 needed its own test. A blocking send never comes back short by itself, so the 1 MB transfer passes with or without the loop. The C test makes the kernel return early: a helper thread signals the sending thread while its 1 MB send is blocked on a stalled reader. That produces both a short return and EINTR.

Changes from Jules's draft

  • Test hooks removed from the shipped library. Four test-only externs were exported from std.ipc and built into libaether: test_ipc_send_two_fds, ipc_test_send_async_raw (a detached pthread), ipc_set_sock_buf_raw and ipc_get_cloexec_raw. They are gone:

    • the hostile peer is now a small C program the integration script builds;
    • close-on-exec is tested by whether a child process actually inherits the descriptor, with a control showing an inherited one does;
    • the 1 MB transfer runs across processes.
  • Fork race. aether_socketpair_cloexec moved to runtime/utils/aether_spawn.c. On macOS (no SOCK_CLOEXEC) it now holds the fork lock across socketpair + fcntl, as aether_pipe_cloexec does. The draft's fallback had no lock, so a concurrent fork could copy the pair into an unrelated child.

  • macOS build. MSG_NOSIGNAL was used unguarded. macOS doesn't define it; it now falls back to 0, and SO_NOSIGPIPE (set on both ends) covers macOS.

  • WASI build. WASI has descriptors but no sockets, and the draft's code didn't compile there (it broke wasi_panic_link, wasm_emit_lib_exports, size_mode and emit_csrc_cross). Without processes, write and read are plain descriptor I/O and send_fd/recv_fd report that they are unavailable.

  • send_fd partial send. The draft returned an error when sendmsg sent only part of the payload. The descriptor has already gone with the first byte by then, so the rest is now sent as plain bytes.

  • recv_fd.

    • The control buffer is aligned.
    • Received descriptors are collected before the message is judged.
    • Each refusal says which rule it broke.
    • End of stream is named separately from "no descriptor".
    • The fcntl fallback runs only where MSG_CMSG_CLOEXEC is missing, so the Linux test of rule 2 can fail.
    • The macOS fork-race window is documented.
  • Errors. Errors used a shared static char buffer and strerror. They now use a per-thread buffer and strerror_r, named "<call>: <reason>". os.socketpair's error was a bare strerror.

  • ipc.write. It tries send once per call and switches to write on ENOTSOCK; the draft made two syscalls per chunk on a pipe.

  • Windows. ipc.write and ipc.read remain plain CRT descriptor I/O there; the draft made write unsupported.

  • Deleted comments restored. The draft removed a lot of documentation:

    • the os_run_pipe_raw contract and its deadlock note;
    • the child-side comment and the env-splicing comment;
    • the Windows rationale in ipc_parent_channel_raw;
    • the send-is-reserved naming note and the usage example in module.ae;
    • the per-function docs.

    They are back, extended to cover sockets.

  • Spec. The spec now uses std.spec instead of exit(1) asserts. It runs on Windows too, checking that each socket entry point reports itself unsupported. The draft's "length preserved" check compared two strings that would both have been truncated; the bytes are now checked past the zero byte.

Checked

  • Spec 11 passing; integration 2 passing; C suite passing.
  • std_ipc_roundtrip, std_ipc_bash_chain, test_os_run_supervised, test_wait_status_signalled and test_fd_read_into pass.
  • Valgrind on the spec, the probe and the child: 0 errors, no definite leaks.
  • make test-ae: 2060 passed. The 6 failures were the four WASI tests above (fixed and rerun) plus windows_crt_symbols and http_server_h2's 50-stream curl stress, which fail the same way on main on this machine.
  • ae fmt, check-docs, check-changelog, check-tests, check-warnings, check-raw-calls, check-extern-widths, check-standalone, check-examples, check-crypto-wipe, check-archive-exports-nozlib, make docs, make test.

Not checked locally: macOS and FreeBSD (CI covers macOS). A control message of another kind (rule 1's third case) has no test. Linux only delivers credentials to a receiver that has set SO_PASSCRED, and the one portable sender for it is FreeBSD's SCM_CREDS.

🤖 Generated with Claude Code

google-labs-jules Bot and others added 4 commits October 31, 2026 01:34
Reshaped std.ipc to support bidirectional Unix domain stream socket
channels and passing open file descriptors across process boundaries
on Linux, FreeBSD, and macOS.

- std.os: added os.socketpair() and os.run_socket() to spawn children
  with a connected AF_UNIX stream socket pair on fd 3.
- std.ipc: added parent_channel_kind(), binary-safe read(), send_fd(),
  and recv_fd(), while preserving parent_channel(), write(), and
  write_close() for backward compatibility with pipe channels.
- Enforced all safety rules: single descriptor per message with leak
  cleanup, receipt close-on-exec (MSG_CMSG_CLOEXEC and fcntl fallback),
  SIGPIPE resistance (MSG_NOSIGNAL and SO_NOSIGPIPE), short write loop
  with EINTR retries, refusal of empty payloads, and descriptive errors.
- Capability gate: added std.ipc under the 'os' capability group for
  --emit=lib compilation.
- Added comprehensive unit spec in std/ipc/test_ipc.ae and integration
  test suite in tests/integration/std_ipc_socket/.

Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com>
Reworks Jules's draft (paul-hammant#46):

- Remove the test-only externs from the shipped library (two-descriptor
  sender, async sender thread, socket-buffer and cloexec probes). The
  hostile peer is now a C program the integration script builds, and
  close-on-exec is tested by whether a child inherits the descriptor.
- aether_socketpair_cloexec moves to runtime/utils/aether_spawn.c and
  takes the fork lock where SOCK_CLOEXEC is missing (macOS), as
  aether_pipe_cloexec does.
- MSG_NOSIGNAL falls back to 0 (macOS relies on SO_NOSIGPIPE); sockets
  are compiled out where there are no processes (WASI).
- send_fd finishes a partial sendmsg rather than failing after the
  descriptor has gone; recv_fd aligns its control buffer, closes every
  descriptor of a refused message, names each refusal, and uses fcntl
  only where MSG_CMSG_CLOEXEC is missing.
- Errors use a per-thread buffer and strerror_r.
- std.ipc imports std.io rather than std.net, so --with=os alone admits
  it under --emit=lib; emit_lib_banned covers it.
- Restore the comments the draft deleted (run_pipe's contract, the
  Windows rationale, the module docs) and extend them to sockets.
- Spec rewritten on std.spec; runs on Windows too. New C test forces
  short and interrupted sends with signals, which a blocking 1 MB
  transfer never produces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS a message carrying more descriptors than recv_fd's control
buffer held came back truncated (MSG_CTRUNC) with cmsg_len still
claiming all of them, so recv_fd read descriptor numbers past the
buffer and closed whatever the stack named: std_ipc_socket's
ten-descriptor case closed the probe's own stdout and exited 1 with
nothing printed. The kernel also installs every descriptor of a
truncated message, so the two it hid stayed open.

recv_fd now counts only the descriptors inside the control data it
received, and makes room for 256, more than one message can carry
(Linux's SCM_MAX_FD is 253; macOS refuses past 254), so nothing is
truncated and every refused descriptor is closed. The hostile peer's
ten descriptors are refused as more than one.

aether_spawn.c includes <sys/socket.h> and makes a socket pair only
where AETHER_HAS_NETWORKING: the embedded Cortex-M4 build has no
sockets, and aether_socketpair_cloexec fails there with ENOSYS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit 17b5cc3 into main Oct 31, 2026
45 checks passed
@paul-hammant
paul-hammant deleted the feat/ipc-socket-fd-passing branch October 31, 2026 07:54
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.

1 participant