From 4998d4e6d33c8aa1acb74e990340028e19e2f7b5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:34:35 +0000 Subject: [PATCH 1/3] feat(ipc): support socket channel and fd passing 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> --- compiler/aetherc.c | 1 + docs/api/index.html | 23 +- docs/api/ipc.html | 89 +++- docs/api/os.html | 24 + docs/stdlib-reference.md | 6 +- ...030T220938Z-added-ipc-socket-fd-passing.md | 1 + runtime/coop_runtime_sources.txt | 1 + std/ipc/README.md | 92 ++-- std/ipc/aether_ipc.c | 465 ++++++++++++++++-- std/ipc/module.ae | 131 +++-- std/ipc/test_ipc.ae | 181 +++++++ std/os/aether_os.c | 138 ++++-- std/os/module.ae | 20 + tests/ae_sweep_prune.txt | 1 + tests/integration/std_ipc_socket/child.ae | 81 +++ tests/integration/std_ipc_socket/probe.ae | 132 +++++ .../std_ipc_socket/test_std_ipc_socket.sh | 31 ++ 17 files changed, 1219 insertions(+), 198 deletions(-) create mode 100644 new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md create mode 100644 std/ipc/test_ipc.ae create mode 100644 tests/integration/std_ipc_socket/child.ae create mode 100644 tests/integration/std_ipc_socket/probe.ae create mode 100644 tests/integration/std_ipc_socket/test_std_ipc_socket.sh diff --git a/compiler/aetherc.c b/compiler/aetherc.c index e08d01b09..f59b49b20 100644 --- a/compiler/aetherc.c +++ b/compiler/aetherc.c @@ -1103,6 +1103,7 @@ static const ModuleCapability module_capabilities[] = { { "std.tcp", "net" }, { "std.udp", "net" }, { "std.os", "os" }, + { "std.ipc", "os" }, { "std.dl", "os" }, { "contrib.host", "extern" }, }; diff --git a/docs/api/index.html b/docs/api/index.html index 806197b1b..6d3af6aa9 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -275,8 +275,8 @@

io

ipc

-

std.ipc — child-to-parent back-channel IPC.

- 4 exports +

std.ipc — child-to-parent and two-way channel IPC with descriptor passing.

+ 17 exports

json

@@ -376,7 +376,7 @@

observe

os

std.os - Shell & Process Execution Import with: import std.os

- 80 exports + 84 exports

path

@@ -1517,9 +1517,22 @@

Search Results

  • fd_read_lineio
  • fd_read_line_statusio
  • parent_channel_rawipc
  • +
  • parent_channel_kind_rawipc
  • +
  • write_rawipc
  • +
  • read_rawipc
  • +
  • send_fd_rawipc
  • +
  • recv_fd_rawipc
  • +
  • set_sock_buf_rawipc
  • +
  • get_cloexec_rawipc
  • +
  • test_ipc_send_two_fdsipc
  • +
  • test_send_async_rawipc
  • parent_channelipc
  • +
  • parent_channel_kindipc
  • writeipc
  • write_closeipc
  • +
  • readipc
  • +
  • send_fdipc
  • +
  • recv_fdipc
  • parse_rawjson
  • last_errorjson
  • stringify_rawjson
  • @@ -2134,6 +2147,10 @@

    Search Results

  • run_pipeos
  • wait_pidos
  • run_pipe_drain_and_waitos
  • +
  • run_socket_rawos
  • +
  • socketpair_rawos
  • +
  • run_socketos
  • +
  • socketpairos
  • spawn_procos
  • waitos
  • wait_anyos
  • diff --git a/docs/api/ipc.html b/docs/api/ipc.html index 9432992c5..a3655cc66 100644 --- a/docs/api/ipc.html +++ b/docs/api/ipc.html @@ -16,9 +16,22 @@

    Aether

    Exports


    Modules

    @@ -118,31 +131,97 @@

    Modules

    ipc

    -

    std.ipc — child-to-parent back-channel IPC.

    +

    std.ipc — child-to-parent and two-way channel IPC with descriptor passing.

    import std.ipc

    parent_channel_raw extern

    parent_channel_raw() -> int
    -

    Returns the parent-channel fd if the parent spawned this process via os.run_pipe (i.e. AETHER_IPC_FD is set in env and the named fd is open); -1 otherwise. Children of os.run / os.run_capture see -1 — those don't open a back-channel. On Windows always -1 (v1 stub). The fd value is whatever the parent picked; current parent-side default is 3 but consumers MUST NOT hardcode that.

    +
    +
    +

    parent_channel_kind_raw extern

    +
    parent_channel_kind_raw() -> string
    +
    +
    +

    write_raw extern

    +
    write_raw(fd, bytes) -> string
    +

    fd: int, bytes: string

    +
    +
    +

    read_raw extern

    +
    read_raw(fd, max) -> (string, string)
    +

    fd: int, max: int

    +
    +
    +

    send_fd_raw extern

    +
    send_fd_raw(ch, fd, payload) -> string
    +

    ch: int, fd: int, payload: string

    +
    +
    +

    recv_fd_raw extern

    +
    recv_fd_raw(ch, max) -> (int, string, string)
    +

    ch: int, max: int

    +
    +
    +

    set_sock_buf_raw extern

    +
    set_sock_buf_raw(fd, size) -> int
    +

    fd: int, size: int

    +
    +
    +

    get_cloexec_raw extern

    +
    get_cloexec_raw(fd) -> int
    +

    fd: int

    +
    +
    +

    test_ipc_send_two_fds extern

    +
    test_ipc_send_two_fds(ch, fd1, fd2, payload) -> int
    +

    ch: int, fd1: int, fd2: int, payload: string

    +
    +
    +

    test_send_async_raw extern

    +
    test_send_async_raw(fd, payload) -> int
    +

    fd: int, payload: string

    parent_channel fn

    parent_channel() -> int
    -

    Aether-side wrappers. parent_channel returns the raw int from the extern; -1 sentinel for "no parent channel."

    +

    Returns the parent-channel fd if the parent spawned this process via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env and the named fd is open); -1 otherwise.

    +
    +
    +

    parent_channel_kind fn

    +
    parent_channel_kind() -> string
    +

    Returns the channel kind: "pipe" for a pipe channel, "socket" for a socket channel, or "" when no channel exists.

    write fn

    write(fd, bytes) -> string

    fd: int, bytes: string

    -

    Write `bytes` to the back-channel `fd`. Returns "" on success, non-empty error string on failure. Wraps std.net.fd_write — the underlying transport is an inherited pipe, so the same byte-stream semantics apply (partial writes are possible on interrupted system calls; the wrapped C extern handles EINTR). For the common case where the child writes one payload and exits, prefer write_close so the parent's read_all loop sees EOF promptly.

    +

    Write `bytes` to `fd`. Returns "" on success, non-empty error string on failure. Loops on short writes and handles SIGPIPE / EINTR.

    write_close fn

    write_close(fd, bytes) -> string

    fd: int, bytes: string

    -

    Write `bytes` then close the channel. The common case: child produces one structured report at exit and closes the fd so the parent's drain loop sees EOF and stops. Calling close releases the writer's reference on the pipe; the parent sees EOF only after every writer has closed. If the child is itself going to exit immediately after, the kernel will close the fd anyway during process teardown — explicit close is still preferred because it lets the parent's reader unblock before the child exits, useful when the parent's reader runs in the same thread as the child-wait loop.

    +

    Write `bytes` then close the channel.

    +
    +
    +

    read fn

    +
    read(ch, max) -> (string, string)
    +

    ch: int, max: int

    +

    Binary-safe read of up to `max` bytes from `fd`. Returns (payload, err). On EOF (end-of-stream), returns ("", ""). On error, returns ("", err).

    +
    +
    +

    send_fd fn

    +
    send_fd(ch, fd, payload) -> string
    +

    ch: int, fd: int, payload: string

    +

    Send one open file descriptor `fd` across socket channel `ch`, accompanied by `payload` (must be at least 1 byte). Returns "" on success, error message on failure.

    +
    +
    +

    recv_fd fn

    +
    recv_fd(ch, max) -> (int, string, string)
    +

    ch: int, max: int

    +

    Receive one open file descriptor and payload up to `max` bytes from socket channel `ch`. Returns (received_fd, payload, err).

    diff --git a/docs/api/os.html b/docs/api/os.html index 84d04d1f2..466fcd449 100644 --- a/docs/api/os.html +++ b/docs/api/os.html @@ -75,6 +75,10 @@

    Exports

  • run_pipe
  • wait_pid
  • run_pipe_drain_and_wait
  • +
  • run_socket_raw
  • +
  • socketpair_raw
  • +
  • run_socket
  • +
  • socketpair
  • spawn_proc
  • wait
  • wait_any
  • @@ -506,6 +510,26 @@

    run_pipe_drain_and_wait fnprog: string, argv: ptr, env: ptr

    Convenience: spawn + drain back-channel pipe + wait + reap, all in one call. Returns (channel_payload, exit_code, err). Recommended entry point for "child writes structured report at exit; parent reads it after." No pipe-buffer deadlock risk regardless of payload size. On Windows, returns ("", -1, "unsupported on Windows").

    +
    +

    run_socket_raw extern

    +
    run_socket_raw(prog, argv, env) -> (int, int, string)
    +

    prog: string, argv: ptr, env: ptr

    +
    +
    +

    socketpair_raw extern

    +
    socketpair_raw() -> (int, int, string)
    +
    +
    +

    run_socket fn

    +
    run_socket(prog, argv, env) -> (int, int, string)
    +

    prog: string, argv: ptr, env: ptr

    +

    Spawn child with std.ipc socket channel (AF_UNIX socketpair). Returns (parent_socket_fd, child_pid, err). Child sees AETHER_IPC_FD=3 and AETHER_IPC_KIND=socket in its environment; std.ipc.parent_channel() resolves to fd 3 and std.ipc.parent_channel_kind() resolves to "socket". Parent end is close-on-exec. Caller MUST call os.wait_pid(child_pid) to reap. On Windows / non-filesystem targets, returns (-1, -1, "unsupported on Windows" or error).

    +
    +
    +

    socketpair fn

    +
    socketpair() -> (int, int, string)
    +

    Create a connected pair of Unix domain sockets (AF_UNIX, SOCK_STREAM). Returns (fd_a, fd_b, err). Both ends are close-on-exec. On Windows / non-filesystem targets, returns (-1, -1, "unsupported on Windows" or error).

    +

    spawn_proc fn

    spawn_proc(prog, argv, env) -> (int, string)
    diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 408791b5b..035176eb9 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -43,7 +43,7 @@ header comment is the authoritative description. | `std.intarr` | Fixed-size packed-int buffer. | 15 | [guide](../std/intarr/README.md) · [source](../std/intarr/module.ae) | | `std.intmap` | Hash map from `long` keys to `long` values, for integer-keyed counting and lookup. | 12 | [guide](../std/intmap/README.md) · [source](../std/intmap/module.ae) | | `std.io` | Console output, whole-file reads and writes, file descriptors, environment variables. | 46 | [full section](#io-stdio) | -| `std.ipc` | Child-to-parent back-channel for processes started by `std.os`. | 4 | [guide](../std/ipc/README.md) · [source](../std/ipc/module.ae) | +| `std.ipc` | Child-to-parent back-channel for processes started by `std.os`. | 17 | [guide](../std/ipc/README.md) · [source](../std/ipc/module.ae) | | `std.json` | JSON parsing, building and serialisation. | 51 | [full section](#json-stdjson) | | `std.jsonpath` | RFC 9535 JSONPath queries over parsed JSON, with a reusable compiled path. | 10 | [guide](../std/jsonpath/README.md) · [source](../std/jsonpath/module.ae) | | `std.ksuid` | KSUID: 160-bit lexicographically sortable identifier. | 1 | [guide](../std/ksuid/README.md) · [source](../std/ksuid/module.ae) | @@ -63,7 +63,7 @@ header comment is the authoritative description. | `std.net` | TCP sockets and the HTTP client and server externs. | 64 | [guide](../std/net/README.md) · [source](../std/net/module.ae) | | `std.number` | Locale-aware number, percent and currency formatting. | 15 | [guide](../std/number/README.md) · [source](../std/number/module.ae) | | `std.observe` | Observable struct models: run closures after every field store on a `struct T @observable` value. | 9 | [guide](../std/observe/README.md) · [source](../std/observe/module.ae) | -| `std.os` | Shell and process execution: run, capture, spawn, pipes, wait. | 80 | [full section](#os-stdos) | +| `std.os` | Shell and process execution: run, capture, spawn, pipes, wait. | 84 | [full section](#os-stdos) | | `std.path` | Lexical path manipulation, with no filesystem access. | 10 | [guide](../std/path/README.md) · [source](../std/path/module.ae) | | `std.plural` | CLDR plural-rule categories. | 2 | [guide](../std/plural/README.md) · [source](../std/plural/module.ae) | | `std.pqueue` | Priority queue over (priority, item) pairs, backed by a binary heap. | 9 | [guide](../std/pqueue/README.md) · [source](../std/pqueue/module.ae) | @@ -3108,6 +3108,8 @@ spawned child exited 5 - `os.wait_pid_timeout(pid, secs)` → `(int, int, string)` - Wait at most `secs` for one child: status, `timed_out`, error. A child that times out is left running - `os.run_supervised(prog, argv, env, new_process_group, forward_signals, timeout_secs, reap_group)` → `(int, string)` - The whole job-control pattern in one call: the child in its own process group, Ctrl-C forwarded to it, a deadline (exit status 124, as GNU `timeout` reports), and anything it leaked cleaned up afterwards. The flags are 1 or 0; returns the exit status and an outcome of `"exited"`, `"signalled"`, `"timeout"` or `"error"`, or -1 and the reason when the child could not start - `os.run_pipe(prog, argv, env)` → `(int, int, string)`, `os.wait_pid(pid)` → `(int, string)`, `os.run_pipe_drain_and_wait(prog, argv, env)` → `(string, int, string)` - Spawn with the [`std.ipc`](#child-to-parent-reports-stdipc) back-channel (POSIX only). A program that cannot start is an `err` with no child to reap; an explicit `env` is the child's whole environment, with `AETHER_IPC_FD=3` in place of any it gives +- `os.run_socket(prog, argv, env)` → `(int, int, string)` - Spawn child with a bidirectional socket channel (`AF_UNIX`, `SOCK_STREAM`), with child descriptor at fd 3 and `AETHER_IPC_FD=3` and `AETHER_IPC_KIND=socket` in its environment +- `os.socketpair()` → `(int, int, string)` - Create a connected pair of Unix domain stream sockets with `O_CLOEXEC` set on both ends - `os_execv(prog, argv_list)` → `int` - Replace this process (on Windows: run it, then exit with its status); listed with the argv accessors above - `os.chdir(path)` → `string`, `os.getcwd()` → `string` - The working directory; `chdir` returns `""` or an error - `os_which(name)` → `string` - The first match for `name` on `PATH`, or `""` diff --git a/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md b/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md new file mode 100644 index 000000000..5d791293f --- /dev/null +++ b/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md @@ -0,0 +1 @@ +- `std.ipc` gained a bidirectional socket channel and file descriptor passing on Linux, FreeBSD, and macOS. `os.run_socket` spawns children with a connected `AF_UNIX` stream socket pair, and `os.socketpair` creates in-process socket pairs. `std.ipc` gained `parent_channel_kind()`, binary-safe `read()`, `send_fd()`, and `recv_fd()`, while preserving `parent_channel()`, `write()`, and `write_close()` for backward-compatible pipe channels. Descriptor passing enforces strict safety rules: exactly one descriptor per message with leak prevention, `FD_CLOEXEC` on receipt, `SIGPIPE` resistance, short write retries, empty payload rejection, and descriptive error messages. diff --git a/runtime/coop_runtime_sources.txt b/runtime/coop_runtime_sources.txt index a2dbad872..0195b9047 100644 --- a/runtime/coop_runtime_sources.txt +++ b/runtime/coop_runtime_sources.txt @@ -68,4 +68,5 @@ std/fs/aether_fs.c std/log/aether_log.c std/io/aether_io.c std/os/aether_os.c +std/ipc/aether_ipc.c std/quote/aether_quote.c diff --git a/std/ipc/README.md b/std/ipc/README.md index 7f04496c7..8fee48631 100644 --- a/std/ipc/README.md +++ b/std/ipc/README.md @@ -1,49 +1,53 @@ # std.ipc -A back-channel to the parent process. - -When a program is spawned by `std.os`'s `run_pipe`, it inherits an extra -descriptor that its parent reads. `std.ipc` is the child's end: a structured -way to report progress or results that does not collide with stdout, which the -parent may be capturing for other reasons. - -The example **compiles but is not run** in CI: it needs a parent that opened -the channel, and outside one `parent_channel` reports that there is none. - -```aether -import std.ipc - -main() { - // A bare fd, or negative when there is no channel; not being - // spawned with one is a normal outcome, not an error. - ch = ipc.parent_channel() - if ch < 0 { - println("no parent channel") - return - } - - werr = ipc.write(ch, "progress: 50%\n") - if werr != "" { - println("write failed: ${werr}") - } - - // write_close sends a final payload and closes, which is what - // signals the parent that no more is coming. - ipc.write_close(ch, "done\n") -} -``` - -The channel is **write-only from the child**: it is a report path, not a -conversation. For bidirectional work, spawn with a socket pair or use actors. - -`write_close(fd, bytes)` writes a last payload and closes in one call. That -close matters: a parent draining the channel blocks until it sees EOF, so a -child that exits without closing leaves the parent waiting until it notices -the process is gone. - -On Windows the parent-side pipe is not implemented (`std.os.run_pipe` is -POSIX-only), so a child there always reports no channel. +Inter-process communication between parent and child processes. + +`std.ipc` provides child-side discovery and bidirectional communication over +channels inherited from `std.os` spawns, as well as file descriptor passing over +stream sockets on Linux, FreeBSD, and macOS. + +## Channel Kinds + +A process spawned by `std.os` may inherit one of two channel kinds: + +1. **`"pipe"`**: A one-way report channel created by `os.run_pipe` or + `os.run_pipe_drain_and_wait`. The child writes into this pipe to send + progress or structured reports without colliding with stdout/stderr. +2. **`"socket"`**: A two-way Unix domain stream socket pair created by + `os.run_socket`. Both parent and child can send and receive binary data and + pass open file descriptors across the boundary. + +`parent_channel()` returns the inherited channel descriptor (negative when none +exists), preserving backward compatibility. `parent_channel_kind()` returns +`"pipe"`, `"socket"`, or `""` if no channel was inherited. + +## Functions + +- `parent_channel() -> int`: Returns the inherited channel descriptor, or `-1` if none. +- `parent_channel_kind() -> string`: Returns `"pipe"`, `"socket"`, or `""`. +- `write(ch: int, data: string) -> string`: Write bytes to channel `ch`. Returns `""` on success or an error message. +- `write_close(ch: int, data: string) -> string`: Write bytes to channel `ch` and close the descriptor. +- `read(ch: int, max: int) -> (string, string)`: Binary-safe read up to `max` bytes. Returns `(data, err)`. On EOF, returns `("", "")`. +- `send_fd(ch: int, fd: int, payload: string) -> string`: Sends one open file descriptor with attached payload (at least 1 byte). Returns `""` on success or an error message. +- `recv_fd(ch: int, max: int) -> (int, string, string)`: Receives one open file descriptor and payload up to `max` bytes. Returns `(fd, payload, err)`. + +## Safety Guarantees + +When passing and receiving file descriptors via `send_fd` and `recv_fd`: + +1. **Exactly one descriptor per message:** If control data is truncated (`MSG_CTRUNC`), or more than one descriptor is received, or an unexpected control message arrives, all received descriptors are closed immediately and an error is returned to prevent descriptor leakage. +2. **Close-on-exec at receipt:** Received descriptors have `FD_CLOEXEC` applied atomically upon receipt (`MSG_CMSG_CLOEXEC` on Linux and FreeBSD; immediate `fcntl` fallback on macOS). +3. **No SIGPIPE:** Socket operations use `MSG_NOSIGNAL` (Linux, FreeBSD) and `SO_NOSIGPIPE` (macOS). Writing to a closed peer returns an error instead of terminating the process with `SIGPIPE`. +4. **Partial writes and EINTR:** Writes and sends loop on partial writes and retry on `EINTR`. Success is never reported on a truncated write. +5. **No silent missing descriptors:** A message without ancillary control data arriving at `recv_fd` produces an explicit error, returning `fd = -1`. +6. **Descriptive error reasons:** Errors detail the failure cause and include OS `strerror(errno)` where appropriate. + +## Platform Support + +- **Linux, FreeBSD, macOS:** Full support for pipe channels, socket channels, and file descriptor passing. +- **Windows:** Pipe back-channel and socket/fd passing APIs return clear "unsupported on Windows" errors. +- **WASM / Minimal builds:** Stubs returning "unavailable" link cleanly in all environments. ## Exports -`parent_channel`, `write`, `write_close`. +`parent_channel`, `parent_channel_kind`, `write`, `write_close`, `read`, `send_fd`, `recv_fd`. diff --git a/std/ipc/aether_ipc.c b/std/ipc/aether_ipc.c index a148d6779..1a3fe5ace 100644 --- a/std/ipc/aether_ipc.c +++ b/std/ipc/aether_ipc.c @@ -1,34 +1,49 @@ -/* aether_ipc.c — child-side IPC primitives. - * - * Companion to std.os.run_pipe (parent side). Provides the - * function the child calls to discover the back-channel fd that - * the parent opened. - * - * Mechanism: the parent sets AETHER_IPC_FD= in the child's - * environment before exec, and dup2's the pipe's write end to - * fd . Child reads getenv("AETHER_IPC_FD"), parses to int, - * verifies the fd is open writable. Returns fd or -1. - * - * The env-var-as-primary design (rather than hardcoded fd 3) - * sidesteps fd-allocation surprises in shell intermediaries - * (e.g. `bash -c ''` between aeb and the test driver). - * - * v1: child-only surface. send / send_close are Aether-side - * wrappers around std.net.fd_write / std.net.fd_close, declared - * in std/ipc/module.ae. */ +/* aether_ipc.c — child-side IPC primitives, two-way socket channels, and descriptor passing. */ #include "../../runtime/config/aether_optimization_config.h" #include #include +#include + +#include "../string/aether_string.h" + +typedef struct { const char* _0; const char* _1; } _tuple_string_string; +typedef struct { int _0; const char* _1; const char* _2; } _tuple_int_string_string; #if !AETHER_HAS_FILESYSTEM + int ipc_parent_channel_raw(void) { return -1; } -#else +const char* ipc_parent_channel_kind_raw(void) { return ""; } +const char* ipc_write_raw(int fd, const void* bytes_obj) { (void)fd; (void)bytes_obj; return "ipc.write unavailable"; } +_tuple_string_string ipc_read_raw(int fd, int max) { + (void)fd; (void)max; + _tuple_string_string out = { (const char*)string_empty(), "ipc.read unavailable" }; + return out; +} +const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { + (void)ch; (void)fd; (void)payload_obj; + return "ipc.send_fd unavailable"; +} +_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { + (void)ch; (void)max; + _tuple_int_string_string out = { -1, (const char*)string_empty(), "ipc.recv_fd unavailable" }; + return out; +} +int ipc_set_sock_buf_raw(int fd, int size) { (void)fd; (void)size; return -1; } +int ipc_get_cloexec_raw(int fd) { (void)fd; return -1; } +int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { (void)ch; (void)fd1; (void)fd2; (void)payload_obj; return -1; } +int ipc_test_send_async_raw(int fd, const void* payload_obj) { (void)fd; (void)payload_obj; return -1; } + +#else /* AETHER_HAS_FILESYSTEM */ #ifndef _WIN32 + #include #include +#include +#include +#include int ipc_parent_channel_raw(void) { const char* s = getenv("AETHER_IPC_FD"); @@ -38,24 +53,414 @@ int ipc_parent_channel_raw(void) { if (end == s || *end != '\0') return -1; if (v < 0 || v > 65535) return -1; int fd = (int)v; - /* Verify the fd is open. fcntl(F_GETFD) returns -1 with - * errno=EBADF if not. */ if (fcntl(fd, F_GETFD) < 0) return -1; return fd; } -#else /* _WIN32 */ +const char* ipc_parent_channel_kind_raw(void) { + int fd = ipc_parent_channel_raw(); + if (fd < 0) return ""; + const char* k = getenv("AETHER_IPC_KIND"); + if (k && strcmp(k, "socket") == 0) return "socket"; + return "pipe"; +} -int ipc_parent_channel_raw(void) { - /* Windows handle inheritance for fd-mapped pipes is - * non-trivial (STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_HANDLE_LIST - * + _open_osfhandle on both sides). Even if AETHER_IPC_FD is - * set, there's no portable way to verify the descriptor is - * open as a pipe via the Win32 CRT layer. v1 ships POSIX-only; - * Windows consumers fall back to file-marker patterns. */ - return -1; +static const char* extract_bytes(const void* obj, size_t* len) { + if (!obj) { + *len = 0; + return ""; + } + if (aether_string_is_string(obj)) { + AetherString* s = (AetherString*)obj; + *len = s->length; + return s->data; + } + *len = strlen((const char*)obj); + return (const char*)obj; } +const char* ipc_write_raw(int fd, const void* bytes_obj) { + if (fd < 0) return "ipc.write: invalid file descriptor"; + size_t len = 0; + const char* data = extract_bytes(bytes_obj, &len); + + size_t total = 0; + while (total < len) { + ssize_t n = send(fd, data + total, len - total, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == ENOTSOCK) { + /* Fallback to write for plain pipes */ + n = write(fd, data + total, len - total); + if (n < 0) { + if (errno == EINTR) continue; + static char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), "ipc.write: write failed: %s", strerror(errno)); + return errbuf; + } + } else { + static char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), "ipc.write: send failed: %s", strerror(errno)); + return errbuf; + } + } + if (n == 0) { + return "ipc.write: short write (0 bytes written)"; + } + total += (size_t)n; + } + return ""; +} + +_tuple_string_string ipc_read_raw(int fd, int max) { + _tuple_string_string out; + out._0 = NULL; + out._1 = ""; + + if (fd < 0) { + out._0 = (const char*)string_empty(); + out._1 = "ipc.read: invalid file descriptor"; + return out; + } + if (max <= 0) { + out._0 = (const char*)string_empty(); + out._1 = "ipc.read: max bytes must be > 0"; + return out; + } + + char* buf = (char*)malloc((size_t)max); + if (!buf) { + out._0 = (const char*)string_empty(); + out._1 = "ipc.read: out of memory"; + return out; + } + + ssize_t n = 0; + while (1) { + n = read(fd, buf, (size_t)max); + if (n < 0) { + if (errno == EINTR) continue; + free(buf); + static char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), "ipc.read: read failed: %s", strerror(errno)); + out._0 = (const char*)string_empty(); + out._1 = errbuf; + return out; + } + break; + } + + if (n == 0) { + /* EOF — return empty string, empty error */ + free(buf); + out._0 = (const char*)string_empty(); + out._1 = ""; + return out; + } + + AetherString* s = (AetherString*)string_new_with_length(buf, (size_t)n); + free(buf); + if (!s) { + out._0 = (const char*)string_empty(); + out._1 = "ipc.read: string allocation failed"; + return out; + } + + out._0 = (const char*)s; + out._1 = ""; + return out; +} + +const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { + if (ch < 0) return "ipc.send_fd: invalid channel descriptor"; + if (fd < 0) return "ipc.send_fd: invalid file descriptor to send"; + + size_t payload_len = 0; + const char* payload = extract_bytes(payload_obj, &payload_len); + + if (payload_len == 0) { + return "ipc.send_fd: empty payload is refused"; + } + + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + + struct iovec iov; + iov.iov_base = (void*)payload; + iov.iov_len = payload_len; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + + char control_buf[CMSG_SPACE(sizeof(int))]; + memset(control_buf, 0, sizeof(control_buf)); + msg.msg_control = control_buf; + msg.msg_controllen = sizeof(control_buf); + + struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); + + while (1) { + ssize_t n = sendmsg(ch, &msg, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + static char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), "ipc.send_fd: sendmsg failed: %s", strerror(errno)); + return errbuf; + } + if ((size_t)n < payload_len) { + return "ipc.send_fd: partial write during sendmsg"; + } + break; + } + + return ""; +} + +_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { + _tuple_int_string_string out; + out._0 = -1; + out._1 = NULL; + out._2 = ""; + + if (ch < 0) { + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: invalid channel descriptor"; + return out; + } + if (max <= 0) { + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: max bytes must be > 0"; + return out; + } + + char* payload_buf = (char*)malloc((size_t)max); + if (!payload_buf) { + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: out of memory"; + return out; + } + + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + + struct iovec iov; + iov.iov_base = payload_buf; + iov.iov_len = (size_t)max; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + + /* Allow buffer for up to 8 descriptors to detect multi-descriptor abuse */ + char control_buf[CMSG_SPACE(sizeof(int) * 8)]; + memset(control_buf, 0, sizeof(control_buf)); + msg.msg_control = control_buf; + msg.msg_controllen = sizeof(control_buf); + + int recv_flags = 0; +#ifdef MSG_CMSG_CLOEXEC + recv_flags |= MSG_CMSG_CLOEXEC; #endif + ssize_t n = 0; + while (1) { + n = recvmsg(ch, &msg, recv_flags); + if (n < 0) { + if (errno == EINTR) continue; + free(payload_buf); + static char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), "ipc.recv_fd: recvmsg failed: %s", strerror(errno)); + out._1 = (const char*)string_empty(); + out._2 = errbuf; + return out; + } + break; + } + + if (n == 0) { + free(payload_buf); + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: end of stream (peer closed)"; + return out; + } + + /* Inspect control data */ + int received_fds[16]; + size_t num_fds = 0; + int has_bad_cmsg = 0; + + if (msg.msg_flags & MSG_CTRUNC) { + has_bad_cmsg = 1; + } + + for (struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) { + has_bad_cmsg = 1; + continue; + } + size_t fd_bytes = cmsg->cmsg_len - CMSG_LEN(0); + size_t fds_in_cmsg = fd_bytes / sizeof(int); + int* fds_ptr = (int*)(void*)CMSG_DATA(cmsg); + for (size_t i = 0; i < fds_in_cmsg; i++) { + if (num_fds < 16) { + received_fds[num_fds++] = fds_ptr[i]; + } else { + close(fds_ptr[i]); + } + } + } + + if (has_bad_cmsg || (msg.msg_flags & MSG_CTRUNC)) { + for (size_t i = 0; i < num_fds; i++) { + close(received_fds[i]); + } + free(payload_buf); + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: control message truncated or invalid"; + return out; + } + + if (num_fds == 0) { + free(payload_buf); + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: message carried no file descriptor"; + return out; + } + + if (num_fds > 1) { + for (size_t i = 0; i < num_fds; i++) { + close(received_fds[i]); + } + free(payload_buf); + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: more than one descriptor in one message"; + return out; + } + + int recvd_fd = received_fds[0]; + + /* Fallback set FD_CLOEXEC for systems lacking MSG_CMSG_CLOEXEC (e.g. macOS) */ + (void)fcntl(recvd_fd, F_SETFD, FD_CLOEXEC); + + AetherString* s = (AetherString*)string_new_with_length(payload_buf, (size_t)n); + free(payload_buf); + + if (!s) { + close(recvd_fd); + out._1 = (const char*)string_empty(); + out._2 = "ipc.recv_fd: payload string allocation failed"; + return out; + } + + out._0 = recvd_fd; + out._1 = (const char*)s; + out._2 = ""; + return out; +} + +int ipc_set_sock_buf_raw(int fd, int size) { + if (fd < 0 || size <= 0) return -1; + setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); + setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + return 0; +} + +int ipc_get_cloexec_raw(int fd) { + if (fd < 0) return -1; + int flags = fcntl(fd, F_GETFD); + if (flags < 0) return -1; + return (flags & FD_CLOEXEC) ? 1 : 0; +} + +int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { + if (ch < 0 || fd1 < 0 || fd2 < 0) return -1; + size_t payload_len = 0; + const char* payload = extract_bytes(payload_obj, &payload_len); + if (payload_len == 0) payload = "x", payload_len = 1; + + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + + struct iovec iov; + iov.iov_base = (void*)payload; + iov.iov_len = payload_len; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + + char control_buf[CMSG_SPACE(sizeof(int) * 2)]; + memset(control_buf, 0, sizeof(control_buf)); + msg.msg_control = control_buf; + msg.msg_controllen = sizeof(control_buf); + + struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_len = CMSG_LEN(sizeof(int) * 2); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + int fds[2] = { fd1, fd2 }; + memcpy(CMSG_DATA(cmsg), fds, sizeof(fds)); + + ssize_t n = sendmsg(ch, &msg, MSG_NOSIGNAL); + return (n > 0) ? 0 : -1; +} + +typedef struct { + int fd; + AetherString* payload; +} AsyncSendArgs; + +static void* async_send_thread(void* arg) { + AsyncSendArgs* args = (AsyncSendArgs*)arg; + ipc_write_raw(args->fd, args->payload); + if (args->payload) string_free(args->payload); + free(args); + return NULL; +} + +int ipc_test_send_async_raw(int fd, const void* payload_obj) { + if (fd < 0) return -1; + size_t len = 0; + const char* data = extract_bytes(payload_obj, &len); + AetherString* copy_str = (AetherString*)string_new_with_length(data, len); + + AsyncSendArgs* args = (AsyncSendArgs*)malloc(sizeof(AsyncSendArgs)); + if (!args) return -1; + args->fd = fd; + args->payload = copy_str; + + pthread_t th; + if (pthread_create(&th, NULL, async_send_thread, args) != 0) { + free(args); + return -1; + } + pthread_detach(th); + return 0; +} + +#else /* _WIN32 */ + +int ipc_parent_channel_raw(void) { return -1; } +const char* ipc_parent_channel_kind_raw(void) { return ""; } +const char* ipc_write_raw(int fd, const void* bytes_obj) { (void)fd; (void)bytes_obj; return "ipc.write unsupported on Windows"; } +_tuple_string_string ipc_read_raw(int fd, int max) { + (void)fd; (void)max; + _tuple_string_string out = { (const char*)string_empty(), "ipc.read unsupported on Windows" }; + return out; +} +const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { + (void)ch; (void)fd; (void)payload_obj; + return "ipc.send_fd unsupported on Windows"; +} +_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { + (void)ch; (void)max; + _tuple_int_string_string out = { -1, (const char*)string_empty(), "ipc.recv_fd unsupported on Windows" }; + return out; +} +int ipc_set_sock_buf_raw(int fd, int size) { (void)fd; (void)size; return -1; } +int ipc_get_cloexec_raw(int fd) { (void)fd; return -1; } +int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { (void)ch; (void)fd1; (void)fd2; (void)payload_obj; return -1; } +int ipc_test_send_async_raw(int fd, const void* payload_obj) { (void)fd; (void)payload_obj; return -1; } + +#endif /* _WIN32 */ + #endif /* AETHER_HAS_FILESYSTEM */ diff --git a/std/ipc/module.ae b/std/ipc/module.ae index 843a43957..b513388c6 100644 --- a/std/ipc/module.ae +++ b/std/ipc/module.ae @@ -1,95 +1,78 @@ -// std.ipc — child-to-parent back-channel IPC. +// std.ipc — child-to-parent and two-way channel IPC with descriptor passing. // -// Companion to std.os.run_pipe (parent side). When a parent -// spawns a child via os.run_pipe / os.run_pipe_drain_and_wait, -// the child can use std.ipc.parent_channel() to find the fd of -// the inherited pipe write end, then write a structured payload -// the parent will read. +// Companion to std.os.run_pipe and std.os.run_socket (parent side). When +// a parent spawns a child via std.os, the child can use std.ipc.parent_channel() +// to find the inherited channel descriptor. // -// Models the brief in -// `~/scm/aetherBuild/asks/lightweight-ipc-dx.md` (DX shape) + -// `lightweight-ipc-dx-aether-reply.md` (proposed surface) + -// `lightweight-ipc-dx-aeb-final.md` (agreement). +// Supports two channel types: +// - "pipe": write-only report pipe created by os.run_pipe / os.run_pipe_drain_and_wait. +// - "socket": bidirectional Unix domain socket created by os.run_socket. // -// Three primitives: -// -// parent_channel() -> int find fd or -1 -// write(fd, bytes) -> string write; "" or err -// write_close(fd, bytes) -> string write + close; "" or err -// -// (Originally named `send`/`send_close` per the design discussion; -// renamed because `send` is a reserved keyword in Aether for -// actor message-passing. `write` reads correctly here — the -// channel IS a writeable file descriptor under the hood.) -// -// `write` / `write_close` wrap `std.net.fd_write` / -// `std.net.fd_close`. Aliased here so a child author who -// imports `std.ipc` for `parent_channel` finds the matching -// write/close functions in the same namespace, instead of -// context-switching to `std.net`. -// -// v1 is POSIX-only. On Windows, parent_channel() always returns -1 -// (the parent-side spawn surface returns "unsupported on Windows" -// too); consumers should fall back to file-marker patterns there. -// -// Common shape — child writes one report at exit: -// -// import std.ipc -// -// ch = ipc.parent_channel() -// if ch >= 0 { -// err = ipc.write_close(ch, "passed=44\nfailed=3\n") -// } +// Functions: +// parent_channel() -> int Find channel fd or -1 +// parent_channel_kind() -> string "pipe", "socket", or "" +// write(fd, bytes) -> string Write bytes; "" or err +// write_close(fd, bytes) -> string Write bytes + close; "" or err +// read(fd, max) -> (data, err) Read up to max bytes; "" data and "" err on EOF +// send_fd(ch, fd, payload) -> string Send 1 descriptor + >=1 byte payload +// recv_fd(ch, max) -> (fd, payload, err) Receive 1 descriptor + payload import std.net exports( - ipc_parent_channel_raw, - parent_channel, write, write_close + ipc_parent_channel_raw, ipc_parent_channel_kind_raw, + ipc_write_raw, ipc_read_raw, ipc_send_fd_raw, ipc_recv_fd_raw, + ipc_set_sock_buf_raw, ipc_get_cloexec_raw, test_ipc_send_two_fds, ipc_test_send_async_raw, + parent_channel, parent_channel_kind, write, write_close, read, send_fd, recv_fd ) -// Returns the parent-channel fd if the parent spawned this process -// via os.run_pipe (i.e. AETHER_IPC_FD is set in env and the named -// fd is open); -1 otherwise. Children of os.run / os.run_capture -// see -1 — those don't open a back-channel. On Windows always -1 -// (v1 stub). The fd value is whatever the parent picked; current -// parent-side default is 3 but consumers MUST NOT hardcode that. extern ipc_parent_channel_raw() -> int +extern ipc_parent_channel_kind_raw() -> string +extern ipc_write_raw(fd: int, bytes: @aether string) -> string +extern ipc_read_raw(fd: int, max: int) -> (string @heap, string) +extern ipc_send_fd_raw(ch: int, fd: int, payload: @aether string) -> string +extern ipc_recv_fd_raw(ch: int, max: int) -> (int, string @heap, string) +extern ipc_set_sock_buf_raw(fd: int, size: int) -> int +extern ipc_get_cloexec_raw(fd: int) -> int +extern test_ipc_send_two_fds(ch: int, fd1: int, fd2: int, payload: string) -> int +extern ipc_test_send_async_raw(fd: int, payload: string) -> int -// Aether-side wrappers. parent_channel returns the raw int from -// the extern; -1 sentinel for "no parent channel." +// Returns the parent-channel fd if the parent spawned this process +// via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env +// and the named fd is open); -1 otherwise. parent_channel() -> int { return ipc_parent_channel_raw() } -// Write `bytes` to the back-channel `fd`. Returns "" on success, -// non-empty error string on failure. Wraps std.net.fd_write — -// the underlying transport is an inherited pipe, so the same -// byte-stream semantics apply (partial writes are possible on -// interrupted system calls; the wrapped C extern handles EINTR). -// -// For the common case where the child writes one payload and -// exits, prefer write_close so the parent's read_all loop sees -// EOF promptly. +// Returns the channel kind: "pipe" for a pipe channel, "socket" for a socket +// channel, or "" when no channel exists. +parent_channel_kind() -> string { return ipc_parent_channel_kind_raw() } + +// Write `bytes` to `fd`. Returns "" on success, non-empty error string +// on failure. Loops on short writes and handles SIGPIPE / EINTR. write(fd: int, bytes: string) -> string { - rc = net.fd_write(fd, bytes) - if rc < 0 { - return "ipc.write: fd_write failed" - } - return "" + return ipc_write_raw(fd, bytes) } -// Write `bytes` then close the channel. The common case: child -// produces one structured report at exit and closes the fd so -// the parent's drain loop sees EOF and stops. -// -// Calling close releases the writer's reference on the pipe; the -// parent sees EOF only after every writer has closed. If the -// child is itself going to exit immediately after, the kernel -// will close the fd anyway during process teardown — explicit -// close is still preferred because it lets the parent's reader -// unblock before the child exits, useful when the parent's -// reader runs in the same thread as the child-wait loop. +// Write `bytes` then close the channel. write_close(fd: int, bytes: string) -> string { werr = write(fd, bytes) net.fd_close(fd) return werr } + +// Binary-safe read of up to `max` bytes from `fd`. Returns (payload, err). +// On EOF (end-of-stream), returns ("", ""). On error, returns ("", err). +read(ch: int, max: int) -> (string @heap, string) { + return ipc_read_raw(ch, max) +} + +// Send one open file descriptor `fd` across socket channel `ch`, accompanied by +// `payload` (must be at least 1 byte). Returns "" on success, error message on failure. +send_fd(ch: int, fd: int, payload: string) -> string { + return ipc_send_fd_raw(ch, fd, payload) +} + +// Receive one open file descriptor and payload up to `max` bytes from socket +// channel `ch`. Returns (received_fd, payload, err). +recv_fd(ch: int, max: int) -> (int, string @heap, string) { + return ipc_recv_fd_raw(ch, max) +} diff --git a/std/ipc/test_ipc.ae b/std/ipc/test_ipc.ae new file mode 100644 index 000000000..fcb4aa54b --- /dev/null +++ b/std/ipc/test_ipc.ae @@ -0,0 +1,181 @@ +// std/ipc/test_ipc.ae — unit spec for std.ipc socket channel & descriptor passing + +import std.ipc +import std.os +import std.fs +import std.io +import std.bytes +import std.string +import std.dir + +extern exit(code: int) + +assert_true(cond: int, msg: string) { + if cond == 0 { + println("FAIL: ${msg}") + exit(1) + } else { + println(" PASS ${msg}") + } +} + +assert_eq_str(got: string, expected: string, msg: string) { + if got != expected { + println("FAIL: ${msg} (got '${got}', expected '${expected}')") + exit(1) + } else { + println(" PASS ${msg}") + } +} + +assert_eq_int(got: int, expected: int, msg: string) { + if got != expected { + println("FAIL: ${msg} (got ${got}, expected ${expected})") + exit(1) + } else { + println(" PASS ${msg}") + } +} + +count_open_fds() -> int { + entries, dir_err = dir.list("/proc/self/fd") + if dir_err != "" || entries == null { return -1 } + c = dir.list_count(entries) + dir.list_free(entries) + return c +} + +main() { + println("=== std.ipc test_ipc ===") + + // 1. Initial state checks + assert_eq_int(ipc.parent_channel(), -1, "parent_channel is -1 when no channel exists") + assert_eq_str(ipc.parent_channel_kind(), "", "parent_channel_kind is empty when no channel exists") + + // 2. os.socketpair checks + fd_a, fd_b, sp_err = os.socketpair() + assert_eq_str(sp_err, "", "socketpair returns valid open descriptors") + assert_true(fd_a >= 0 && fd_b >= 0, "socketpair fds are non-negative") + assert_true(ipc.get_cloexec_raw(fd_a) != 0, "socketpair fd_a is close-on-exec") + assert_true(ipc.get_cloexec_raw(fd_b) != 0, "socketpair fd_b is close-on-exec") + + // 3. Bidirectional write / read roundtrips (including embedded NUL byte) + msg_zero = string.concat("hello\0world", "!!") + w_err1 = ipc.write(fd_a, msg_zero) + assert_eq_str(w_err1, "", "ipc.write with embedded zero byte succeeds") + + read_data1, r_err1 = ipc.read(fd_b, 100) + assert_eq_str(r_err1, "", "ipc.read succeeds") + assert_eq_int(string.length(read_data1), string.length(msg_zero), "read payload preserves length with zero byte") + assert_eq_str(read_data1, msg_zero, "read payload matches msg_zero") + + // Reverse direction (fd_b -> fd_a) + rev_msg = "hello from b to a" + w_err_rev = ipc.write(fd_b, rev_msg) + assert_eq_str(w_err_rev, "", "ipc.write reverse b to a succeeds") + read_rev, r_err_rev = ipc.read(fd_a, 100) + assert_eq_str(r_err_rev, "", "ipc.read reverse on a succeeds") + assert_eq_str(read_rev, rev_msg, "reverse payload matches") + + // 4. Large payload roundtrip (1 MB) to test short write looping / EINTR handling + mb_chunk = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" // 64 bytes + // Build 1 MB = 16384 * 64 bytes + big_buf = string.concat("", "") + i = 0 + while i < 16384 { + big_buf = string.concat(big_buf, mb_chunk) + i = i + 1 + } + assert_eq_int(string.length(big_buf), 1048576, "constructed 1 MB payload") + + // Increase socket buffers to facilitate 1 MB transfers + _ = ipc.set_sock_buf_raw(fd_a, 262144) + _ = ipc.set_sock_buf_raw(fd_b, 262144) + + // Send asynchronously in a helper thread to avoid deadlock if socket buffer fills + _ = ipc.test_send_async_raw(fd_a, big_buf) + + // Read 1 MB in chunks + recvd_big = string.concat("", "") + chunk = "" + chunk_err = "" + while string.length(recvd_big) < 1048576 { + chunk, chunk_err = ipc.read(fd_b, 65536) + assert_eq_str(chunk_err, "", "chunk read error is empty") + if string.length(chunk) == 0 { break } + recvd_big = string.concat(recvd_big, chunk) + } + assert_eq_int(string.length(recvd_big), 1048576, "read 1 MB payload length matches") + assert_eq_str(recvd_big, big_buf, "1 MB payload content matches") + + // 5. Descriptor passing (Rule 2: close-on-exec, Rule 5: refusal of empty payload) + tmp_path = "${os.temp_dir()}/std_ipc_test_file_${os.getpid()}.txt" + _ = fs.write_atomic(tmp_path, string.bytes("fd passing content\n")) + + t_fd, t_err = io.fd_open_read(tmp_path) + assert_eq_str(t_err, "", "opened temp file for reading") + + sfd_err = ipc.send_fd(fd_a, t_fd, "check_file") + assert_eq_str(sfd_err, "", "send_fd succeeds") + _ = io.fd_close(t_fd) + + recvd_fd, payload, rfd_err = ipc.recv_fd(fd_b, 100) + assert_eq_str(rfd_err, "", "recv_fd succeeds") + assert_true(recvd_fd >= 0, "received valid descriptor") + assert_eq_str(payload, "check_file", "received payload matches") + assert_true(ipc.get_cloexec_raw(recvd_fd) != 0, "received descriptor is close-on-exec") + + // Verify reading through received descriptor + buf_recvd = bytes.new(256) + n_recvd, read_f_err = io.fd_read_into(recvd_fd, bytes.capacity_view(buf_recvd)[0..256]) + assert_eq_str(read_f_err, "", "read from received descriptor succeeds") + _ = bytes.set_length(buf_recvd, n_recvd) + file_content = bytes.to_string(buf_recvd, n_recvd) + bytes.free(buf_recvd) + assert_eq_str(file_content, "fd passing content\n", "read content through received descriptor matches") + _ = io.fd_close(recvd_fd) + _ = fs.delete(tmp_path) + + // 6. Refusal of empty payload in send_fd + empty_sfd_err = ipc.send_fd(fd_a, fd_a, "") + assert_true(empty_sfd_err != "", "send_fd with empty payload is refused") + + // 7. Message without descriptor sent to recv_fd (Rule 5) + _ = ipc.write(fd_a, "plain payload no fd") + no_fd, _no_fd_payload, no_fd_err = ipc.recv_fd(fd_b, 100) + assert_eq_int(no_fd, -1, "recv_fd without descriptor returns fd=-1") + assert_true(no_fd_err != "", "recv_fd without descriptor returns non-empty error") + + // 8. SIGPIPE resistance (Rule 3): write to closed peer returns error + _ = io.fd_close(fd_b) + pipe_err = ipc.write(fd_a, "should fail quietly without SIGPIPE") + assert_true(pipe_err != "", "write to closed peer returns error") + _ = io.fd_close(fd_a) + + // 9. Linux leak test (Rule 1): sending multiple descriptors in one message + initial_fd_count = count_open_fds() + if initial_fd_count >= 0 { + s_a, s_b, _ = os.socketpair() + dummy1, _, _ = os.socketpair() + dummy2, _, _ = os.socketpair() + + before_fds = count_open_fds() + send_two_rc = ipc.test_ipc_send_two_fds(s_a, dummy1, dummy2, "two_fds_payload") + assert_eq_int(send_two_rc, 0, "sent two descriptors in one message via helper") + + bad_fd, _bad_payload, bad_err = ipc.recv_fd(s_b, 100) + assert_eq_int(bad_fd, -1, "recv_fd errors on multiple descriptors") + assert_true(bad_err != "", "recv_fd returns error on multiple descriptors") + + after_fds = count_open_fds() + assert_eq_int(after_fds, before_fds, "proc fd count is unchanged (no leaked descriptors)") + + _ = io.fd_close(s_a) + _ = io.fd_close(s_b) + _ = io.fd_close(dummy1) + _ = io.fd_close(dummy2) + } + + println("All PASS") + return +} diff --git a/std/os/aether_os.c b/std/os/aether_os.c index 84b476455..58b656a6d 100644 --- a/std/os/aether_os.c +++ b/std/os/aether_os.c @@ -58,6 +58,15 @@ _tuple_int_int_string os_run_pipe_raw(const char* p, void* a, void* e) { _tuple_int_int_string out = { -1, -1, "os.run_pipe unavailable" }; return out; } +_tuple_int_int_string os_run_socket_raw(const char* p, void* a, void* e) { + (void)p; (void)a; (void)e; + _tuple_int_int_string out = { -1, -1, "os.run_socket unavailable" }; + return out; +} +_tuple_int_int_string os_socketpair_raw(void) { + _tuple_int_int_string out = { -1, -1, "os.socketpair unavailable" }; + return out; +} _tuple_int_string os_wait_pid_raw(int pid) { (void)pid; _tuple_int_string out = { -1, "os.wait_pid unavailable" }; @@ -1861,29 +1870,54 @@ typedef struct { int _0; int _1; int _2; const char* _3; } _tuple_int_int_int_st * takes the environment's lock, and a lock another thread held at the fork * is never released in the child. The array is malloc'd; its strings are * the base's and a literal. NULL when malloc fails. */ +#include + static char** aether_ipc_env(char** base) { static const char* const ipc[] = { "AETHER_IPC_FD=3" }; return aether_spawn_env(base ? base : aether_environ, ipc, 1); } -/* Spawn child with a back-channel pipe at fd 3 (write end), set - * AETHER_IPC_FD=3 in child's env. Returns (parent_read_fd, - * child_pid, err). On success, err is "" and caller must: - * 1. Read from parent_read_fd until EOF. - * 2. Call os_wait_pid_raw(child_pid) to reap. - * 3. Close parent_read_fd. - * - * Failure modes return (-1, -1, "") with no child left to reap: - * a program that could not start reads so too, the reason from its exec - * (#3170). An explicit env is refused before the fork: the child's - * AETHER_IPC_FD would have to be spliced into it, and it used to make the - * child exit 127 without running the program. - * - * Pipe-buffer deadlock window: if child writes > OS pipe buffer - * (typically 16K-64K) without parent reading concurrently, the - * child blocks forever on its write. For payload-at-exit usage - * with large payloads, prefer os_run_pipe_drain_and_wait_raw. */ -_tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* env_list) { +static char** aether_ipc_socket_env(char** base) { + static const char* const ipc[] = { "AETHER_IPC_FD=3", "AETHER_IPC_KIND=socket" }; + return aether_spawn_env(base ? base : aether_environ, ipc, 2); +} + +static int aether_socketpair_cloexec(int fds[2]) { +#if defined(SOCK_CLOEXEC) + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) == 0) { +#ifdef SO_NOSIGPIPE + int val = 1; + setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); + setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); +#endif + return 0; + } + if (errno != EINVAL) return -1; +#endif + if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) < 0) return -1; + (void)fcntl(fds[0], F_SETFD, FD_CLOEXEC); + (void)fcntl(fds[1], F_SETFD, FD_CLOEXEC); +#ifdef SO_NOSIGPIPE + int val = 1; + setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); + setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); +#endif + return 0; +} + +_tuple_int_int_string os_socketpair_raw(void) { + _tuple_int_int_string out = { -1, -1, "" }; + int fds[2]; + if (aether_socketpair_cloexec(fds) < 0) { + out._2 = strerror(errno); + return out; + } + out._0 = fds[0]; + out._1 = fds[1]; + return out; +} + +static _tuple_int_int_string aether_spawn_channel_raw(const char* prog, void* argv_list, void* env_list, int is_socket) { _tuple_int_int_string out = { -1, -1, "" }; if (!prog) { out._2 = "null prog"; @@ -1894,16 +1928,23 @@ _tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* e return out; } - - int pipefd[2]; - if (aether_pipe_cloexec(pipefd) != 0) { - out._2 = "pipe failed"; - return out; + int chanfd[2]; + if (is_socket) { + if (aether_socketpair_cloexec(chanfd) != 0) { + out._2 = "socketpair failed"; + return out; + } + } else { + if (aether_pipe_cloexec(chanfd) != 0) { + out._2 = "pipe failed"; + return out; + } } + int errp[2]; if (aether_pipe_cloexec(errp) != 0) { - close(pipefd[0]); - close(pipefd[1]); + close(chanfd[0]); + close(chanfd[1]); out._2 = "pipe failed"; return out; } @@ -1911,21 +1952,20 @@ _tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* e AetherExec x; const char* xerr = aether_exec_prepare(&x, prog, argv_list, env_list); if (xerr) { - close(pipefd[0]); - close(pipefd[1]); + close(chanfd[0]); + close(chanfd[1]); close(errp[0]); close(errp[1]); out._2 = xerr; return out; } - /* The child's env: the one given, or environ, with AETHER_IPC_FD=3. The - * spliced array borrows the given one's strings, so only its array goes. */ - char** spliced = aether_ipc_env(x.envp); + + char** spliced = is_socket ? aether_ipc_socket_env(x.envp) : aether_ipc_env(x.envp); free(x.envp); x.envp = spliced; if (!x.envp) { - close(pipefd[0]); - close(pipefd[1]); + close(chanfd[0]); + close(chanfd[1]); close(errp[0]); close(errp[1]); aether_exec_release(&x); @@ -1936,8 +1976,8 @@ _tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* e pid_t pid = aether_fork(); if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); + close(chanfd[0]); + close(chanfd[1]); close(errp[0]); close(errp[1]); aether_exec_release(&x); @@ -1945,33 +1985,40 @@ _tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* e return out; } if (pid == 0) { - /* Child — wire pipefd[1] to fd 3; its env says so. */ aether_child_reset_signals(); aether_child_keep_stdio(); - close(pipefd[0]); + close(chanfd[0]); close(errp[0]); int ef = aether_child_lift_fd(errp[1]); - int chan = aether_child_lift_fd(pipefd[1]); + int chan = aether_child_lift_fd(chanfd[1]); if (aether_child_fd(chan, 3) < 0) aether_child_exec_failed(ef); close(chan); aether_child_exec(&x); aether_child_exec_failed(ef); } - close(pipefd[1]); + close(chanfd[1]); aether_exec_release(&x); int exec_err = aether_exec_errno(errp); if (exec_err) { - close(pipefd[0]); + close(chanfd[0]); aether_reap_failed(pid); out._2 = aether_exec_failure(exec_err); return out; } - out._0 = pipefd[0]; + out._0 = chanfd[0]; out._1 = pid; return out; } +_tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* env_list) { + return aether_spawn_channel_raw(prog, argv_list, env_list, 0); +} + +_tuple_int_int_string os_run_socket_raw(const char* prog, void* argv_list, void* env_list) { + return aether_spawn_channel_raw(prog, argv_list, env_list, 1); +} + /* Wait for a child, return (exit_code, err). Reaps the child so * it doesn't become a zombie. Companion to os_run_pipe_raw. */ _tuple_int_string os_wait_pid_raw(int pid) { @@ -3928,6 +3975,17 @@ _tuple_int_int_string os_run_pipe_raw(const char* prog, void* argv_list, void* e return out; } +_tuple_int_int_string os_run_socket_raw(const char* prog, void* argv_list, void* env_list) { + (void)prog; (void)argv_list; (void)env_list; + _tuple_int_int_string out = { -1, -1, "unsupported on Windows" }; + return out; +} + +_tuple_int_int_string os_socketpair_raw(void) { + _tuple_int_int_string out = { -1, -1, "unsupported on Windows" }; + return out; +} + _tuple_int_string os_wait_pid_raw(int pid) { /* pid here is the token minted by os_run_pipe_raw / os_spawn_raw. */ return os_wait_raw(pid); diff --git a/std/os/module.ae b/std/os/module.ae index ad9d29709..4e8383644 100644 --- a/std/os/module.ae +++ b/std/os/module.ae @@ -24,6 +24,7 @@ exports( platform, arch, temp_dir, cpu_count, setenv, unsetenv, run_pipe, wait_pid, run_pipe_drain_and_wait, + os_run_socket_raw, os_socketpair_raw, run_socket, socketpair, spawn_proc, wait, wait_any, wait_any_timeout, os_spawn_to_raw, spawn_proc_to, os_kill_raw, os_wait_pid_timeout_raw, os_run_supervised_raw, @@ -106,6 +107,8 @@ extern os_run_capture_status_raw(prog: string, argv: ptr, env: ptr) -> (string @ // explicit env is the child's whole environment, with AETHER_IPC_FD=3 in // place of any it gives. extern os_run_pipe_raw(prog: string, argv: ptr, env: ptr) -> (int, int, string) +extern os_run_socket_raw(prog: string, argv: ptr, env: ptr) -> (int, int, string) +extern os_socketpair_raw() -> (int, int, string) // Wait for a child to exit and reap it. Companion to // os_run_pipe_raw. Returns (exit_code, err). Calling this @@ -349,6 +352,23 @@ run_pipe(prog: string, argv: ptr, env: ptr) -> { return os_run_pipe_raw(prog, argv, env) } +// Spawn child with std.ipc socket channel (AF_UNIX socketpair). +// Returns (parent_socket_fd, child_pid, err). Child sees AETHER_IPC_FD=3 +// and AETHER_IPC_KIND=socket in its environment; std.ipc.parent_channel() +// resolves to fd 3 and std.ipc.parent_channel_kind() resolves to "socket". +// Parent end is close-on-exec. Caller MUST call os.wait_pid(child_pid) to reap. +// On Windows / non-filesystem targets, returns (-1, -1, "unsupported on Windows" or error). +run_socket(prog: string, argv: ptr, env: ptr) -> { + return os_run_socket_raw(prog, argv, env) +} + +// Create a connected pair of Unix domain sockets (AF_UNIX, SOCK_STREAM). +// Returns (fd_a, fd_b, err). Both ends are close-on-exec. +// On Windows / non-filesystem targets, returns (-1, -1, "unsupported on Windows" or error). +socketpair() -> { + return os_socketpair_raw() +} + // Wait for and reap a child spawned via os.run_pipe. Returns // (exit_code, err). Calling this is required after os.run_pipe to // avoid leaving the child as a zombie process. diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index 5f37d9e20..6c4f1d323 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -257,6 +257,7 @@ tests/integration/spec_why_message/ tests/integration/std_dl/ tests/integration/std_ipc_bash_chain/ tests/integration/std_ipc_roundtrip/ +tests/integration/std_ipc_socket/ tests/integration/std_mem_byte_access/ tests/integration/std_strbuilder_csv/ tests/integration/std_strbuilder_no_leak/ diff --git a/tests/integration/std_ipc_socket/child.ae b/tests/integration/std_ipc_socket/child.ae new file mode 100644 index 000000000..76c1f887c --- /dev/null +++ b/tests/integration/std_ipc_socket/child.ae @@ -0,0 +1,81 @@ +// Integration test child process for std.ipc socket channel and fd passing. + +import std.ipc +import std.io +import std.bytes +import std.string + +extern exit(code: int) + +main() { + ch = ipc.parent_channel() + kind = ipc.parent_channel_kind() + + if ch < 0 { + println("FAIL child: no parent channel") + exit(1) + } + + if kind != "socket" { + println("FAIL child: expected socket channel, got '${kind}'") + exit(1) + } + + // 1. Read request from parent + req, rerr = ipc.read(ch, 100) + if rerr != "" { + println("FAIL child read request: ${rerr}") + exit(1) + } + if req != "hello child" { + println("FAIL child read request mismatch: got '${req}'") + exit(1) + } + + // 2. Reply to parent + werr = ipc.write(ch, "hello parent") + if werr != "" { + println("FAIL child write reply: ${werr}") + exit(1) + } + + // 3. Receive file descriptor from parent + recvd_fd, payload, recv_err = ipc.recv_fd(ch, 1024) + if recv_err != "" { + println("FAIL child recv_fd: ${recv_err}") + exit(1) + } + if recvd_fd < 0 { + println("FAIL child recv_fd invalid descriptor: ${recvd_fd}") + exit(1) + } + if payload != "read_file_req" { + println("FAIL child recv_fd payload mismatch: got '${payload}'") + exit(1) + } + + // 4. Read file through received descriptor + buf = bytes.new(256) + n_read, read_err = io.fd_read_into(recvd_fd, bytes.capacity_view(buf)[0..256]) + if read_err != "" { + println("FAIL child fd_read_into: ${read_err}") + bytes.free(buf) + exit(1) + } + _ = bytes.set_length(buf, n_read) + content = bytes.to_string(buf, n_read) + bytes.free(buf) + + // Close received descriptor + _ = io.fd_close(recvd_fd) + + // 5. Send file contents back to parent + werr2 = ipc.write(ch, "content: ${content}") + if werr2 != "" { + println("FAIL child write content reply: ${werr2}") + exit(1) + } + + _ = io.fd_close(ch) + exit(0) +} diff --git a/tests/integration/std_ipc_socket/probe.ae b/tests/integration/std_ipc_socket/probe.ae new file mode 100644 index 000000000..9d0160839 --- /dev/null +++ b/tests/integration/std_ipc_socket/probe.ae @@ -0,0 +1,132 @@ +// std.ipc socket channel and descriptor passing integration test probe. + +import std.ipc +import std.os +import std.fs +import std.io +import std.string +import std.list + +extern exit(code: int) + +const CHILD_SRC = "tests/integration/std_ipc_socket/child.ae" + +build_child(bin: string) -> string { + argv = list.new() + list.add(argv, "build") + list.add(argv, CHILD_SRC) + list.add(argv, "-o") + list.add(argv, bin) + out, exit_code, err = os.run_capture("build/ae", argv, null) + list.free(argv) + if err != "" { return "spawn error: ${err}" } + if exit_code != 0 { + return "ae build exit ${exit_code}: ${out}" + } + return "" +} + +main() { + println("std_ipc_socket test probe") + + child_bin = "${os.temp_dir()}/std_ipc_socket_child_${os.getpid()}" + berr = build_child(child_bin) + if berr != "" { + println("FAIL: cannot build child: ${berr}") + exit(1) + } + + // Create temp file for descriptor passing + tmp_path = "${os.temp_dir()}/std_ipc_socket_file_${os.getpid()}.txt" + _ = fs.write_atomic(tmp_path, string.bytes("secret data 42\n")) + + // Spawn child via os.run_socket + argv = list.new() + parent_fd, child_pid, spawn_err = os.run_socket(child_bin, argv, null) + list.free(argv) + + if spawn_err != "" { + println("FAIL os.run_socket: ${spawn_err}") + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + if parent_fd < 0 || child_pid <= 0 { + println("FAIL os.run_socket invalid return: fd=${parent_fd}, pid=${child_pid}") + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + // 1. Send request to child + werr = ipc.write(parent_fd, "hello child") + if werr != "" { + println("FAIL write request: ${werr}") + _ = io.fd_close(parent_fd) + _ = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + // 2. Read reply from child + rep_msg, rerr = ipc.read(parent_fd, 100) + if rerr != "" || rep_msg != "hello parent" { + println("FAIL read reply: err='${rerr}', rep_msg='${rep_msg}'") + _ = io.fd_close(parent_fd) + _ = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + // 3. Open temp file and send descriptor + f_fd, f_err = io.fd_open_read(tmp_path) + if f_err != "" || f_fd < 0 { + println("FAIL open temp file: err='${f_err}', fd=${f_fd}") + _ = io.fd_close(parent_fd) + _ = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + send_err = ipc.send_fd(parent_fd, f_fd, "read_file_req") + _ = io.fd_close(f_fd) // parent can close its handle after sending + + if send_err != "" { + println("FAIL send_fd: ${send_err}") + _ = io.fd_close(parent_fd) + _ = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + // 4. Read report back from child + report, report_err = ipc.read(parent_fd, 1024) + if report_err != "" || report != "content: secret data 42\n" { + println("FAIL read report: err='${report_err}', report='${report}'") + _ = io.fd_close(parent_fd) + _ = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + exit(1) + } + + _ = io.fd_close(parent_fd) + + // Reap child + exit_code, wpid_err = os.wait_pid(child_pid) + _ = fs.delete(tmp_path) + _ = fs.delete(child_bin) + + if wpid_err != "" || exit_code != 0 { + println("FAIL wait_pid: err='${wpid_err}', exit_code=${exit_code}") + exit(1) + } + + println("PASS std_ipc_socket round-trip and fd-passing successful") + exit(0) +} diff --git a/tests/integration/std_ipc_socket/test_std_ipc_socket.sh b/tests/integration/std_ipc_socket/test_std_ipc_socket.sh new file mode 100644 index 000000000..f66689401 --- /dev/null +++ b/tests/integration/std_ipc_socket/test_std_ipc_socket.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) + echo " [SKIP] std_ipc_socket: std.ipc socket channel is POSIX-only in v1" + exit 0 ;; +esac + +if [ ! -x "$AE" ]; then + echo " [SKIP] std_ipc_socket: ae not built" + exit 0 +fi + +cd "$ROOT" || exit 1 + +TMPDIR="$(mktemp -d)" +export TMPDIR +trap 'rm -rf "$TMPDIR" || true' EXIT + +if ! AETHER_HOME="$ROOT" "$AE" run "$SCRIPT_DIR/probe.ae" >"$TMPDIR/out.log" 2>&1; then + echo " [FAIL] std_ipc_socket" + tail -40 "$TMPDIR/out.log" | sed 's/^/ /' + exit 1 +fi + +echo " [PASS] std_ipc_socket" From 56ff4ca48f9d97354596a12952f64ccadc2e6dab Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sat, 31 Oct 2026 05:07:34 +0000 Subject: [PATCH 2/3] std.ipc: shape the socket channel and descriptor passing Reworks Jules's draft (paul-hammant/aether-fork#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 --- Makefile | 1 + docs/api/index.html | 8 +- docs/api/ipc.html | 41 +- docs/emit-lib.md | 5 +- docs/stdlib-reference.md | 12 +- ...030T220938Z-added-ipc-socket-fd-passing.md | 21 +- runtime/utils/aether_spawn.c | 25 + runtime/utils/aether_spawn.h | 8 + std/ipc/README.md | 157 ++++- std/ipc/aether_ipc.c | 661 ++++++++---------- std/ipc/module.ae | 126 +++- std/ipc/test_ipc.ae | 331 ++++----- std/os/aether_os.c | 76 +- .../emit_lib_banned/test_emit_lib_banned.sh | 2 +- tests/integration/std_ipc_socket/child.ae | 100 ++- .../integration/std_ipc_socket/hostile_peer.c | 70 ++ tests/integration/std_ipc_socket/probe.ae | 227 +++--- .../std_ipc_socket/test_std_ipc_socket.sh | 28 +- tests/runtime/test_ipc_write.c | 107 +++ 19 files changed, 1180 insertions(+), 826 deletions(-) create mode 100644 tests/integration/std_ipc_socket/hostile_peer.c create mode 100644 tests/runtime/test_ipc_write.c diff --git a/Makefile b/Makefile index 734a3b820..b64278068 100644 --- a/Makefile +++ b/Makefile @@ -960,6 +960,7 @@ TEST_SRC = tests/runtime/test_harness.c \ tests/runtime/test_runtime_observe.c \ tests/runtime/test_runtime_process_mem.c \ tests/runtime/test_spawn_env.c \ + tests/runtime/test_ipc_write.c \ tests/runtime/test_runtime_schema.c \ tests/runtime/test_runtime_intmap.c \ tests/runtime/test_tools_sha256.c tools/ae_sha256.c \ diff --git a/docs/api/index.html b/docs/api/index.html index 6d3af6aa9..b582c9db5 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -275,8 +275,8 @@

    io

    ipc

    -

    std.ipc — child-to-parent and two-way channel IPC with descriptor passing.

    - 17 exports +

    std.ipc — a process's channel to its parent: a report pipe, or a two-way socket that can also pass open file descriptors.

    + 13 exports

    json

    @@ -1522,10 +1522,6 @@

    Search Results

  • read_rawipc
  • send_fd_rawipc
  • recv_fd_rawipc
  • -
  • set_sock_buf_rawipc
  • -
  • get_cloexec_rawipc
  • -
  • test_ipc_send_two_fdsipc
  • -
  • test_send_async_rawipc
  • parent_channelipc
  • parent_channel_kindipc
  • writeipc
  • diff --git a/docs/api/ipc.html b/docs/api/ipc.html index a3655cc66..e35772582 100644 --- a/docs/api/ipc.html +++ b/docs/api/ipc.html @@ -21,10 +21,6 @@

    Exports

  • read_raw
  • send_fd_raw
  • recv_fd_raw
  • -
  • set_sock_buf_raw
  • -
  • get_cloexec_raw
  • -
  • test_ipc_send_two_fds
  • -
  • test_send_async_raw
  • parent_channel
  • parent_channel_kind
  • write
  • @@ -131,13 +127,14 @@

    Modules

    ipc

    -

    std.ipc — child-to-parent and two-way channel IPC with descriptor passing.

    +

    std.ipc — a process's channel to its parent: a report pipe, or a two-way socket that can also pass open file descriptors.

    import std.ipc

    parent_channel_raw extern

    parent_channel_raw() -> int
    +

    Returns the parent-channel fd if the parent spawned this process via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env and the named fd is open); -1 otherwise. Children of os.run / os.run_capture see -1 — those don't open a back-channel. On Windows always -1 (v1 stub). The fd value is whatever the parent picked; current parent-side default is 3 but consumers MUST NOT hardcode that.

    parent_channel_kind_raw extern

    @@ -163,65 +160,45 @@

    recv_fd_raw extern

    recv_fd_raw(ch, max) -> (int, string, string)

    ch: int, max: int

    -
    -

    set_sock_buf_raw extern

    -
    set_sock_buf_raw(fd, size) -> int
    -

    fd: int, size: int

    -
    -
    -

    get_cloexec_raw extern

    -
    get_cloexec_raw(fd) -> int
    -

    fd: int

    -
    -
    -

    test_ipc_send_two_fds extern

    -
    test_ipc_send_two_fds(ch, fd1, fd2, payload) -> int
    -

    ch: int, fd1: int, fd2: int, payload: string

    -
    -
    -

    test_send_async_raw extern

    -
    test_send_async_raw(fd, payload) -> int
    -

    fd: int, payload: string

    -

    parent_channel fn

    parent_channel() -> int
    -

    Returns the parent-channel fd if the parent spawned this process via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env and the named fd is open); -1 otherwise.

    +

    Aether-side wrappers. parent_channel returns the raw int from the extern; -1 sentinel for "no parent channel."

    parent_channel_kind fn

    parent_channel_kind() -> string
    -

    Returns the channel kind: "pipe" for a pipe channel, "socket" for a socket channel, or "" when no channel exists.

    +

    "socket" for a channel from os.run_socket (two-way, can pass descriptors), "pipe" for one from os.run_pipe (write-only from the child), "" when parent_channel() finds none.

    write fn

    write(fd, bytes) -> string

    fd: int, bytes: string

    -

    Write `bytes` to `fd`. Returns "" on success, non-empty error string on failure. Loops on short writes and handles SIGPIPE / EINTR.

    +

    Write all of `bytes` to `fd`. Returns "" on success, non-empty error string on failure. A short write continues where it stopped and an interrupted one retries, so "" means every byte went; a string with a zero byte in it goes whole. On a socket whose peer has gone this is an error, not SIGPIPE. For the common case where the child writes one payload and exits, prefer write_close so the parent's read_all loop sees EOF promptly.

    write_close fn

    write_close(fd, bytes) -> string

    fd: int, bytes: string

    -

    Write `bytes` then close the channel.

    +

    Write `bytes` then close the channel. The common case: child produces one structured report at exit and closes the fd so the parent's drain loop sees EOF and stops. Calling close releases the writer's reference on the pipe; the parent sees EOF only after every writer has closed. If the child is itself going to exit immediately after, the kernel will close the fd anyway during process teardown — explicit close is still preferred because it lets the parent's reader unblock before the child exits, useful when the parent's reader runs in the same thread as the child-wait loop.

    read fn

    read(ch, max) -> (string, string)

    ch: int, max: int

    -

    Binary-safe read of up to `max` bytes from `fd`. Returns (payload, err). On EOF (end-of-stream), returns ("", ""). On error, returns ("", err).

    +

    One read of up to `max` bytes: (data, "") with what arrived, which may be less than was written (the channel is a byte stream; framing is the caller's), ("", "") at end of stream, or ("", err). Binary-safe. Blocks until something arrives.

    send_fd fn

    send_fd(ch, fd, payload) -> string

    ch: int, fd: int, payload: string

    -

    Send one open file descriptor `fd` across socket channel `ch`, accompanied by `payload` (must be at least 1 byte). Returns "" on success, error message on failure.

    +

    Pass the open descriptor `fd` to the process at the other end of socket `ch`, attached to `payload`, which must be at least one byte (on a stream socket a descriptor travels with bytes). The receiver gets its own descriptor for the same open file; this side's `fd` stays open, so close it when this side is done with it.

    recv_fd fn

    recv_fd(ch, max) -> (int, string, string)

    ch: int, max: int

    -

    Receive one open file descriptor and payload up to `max` bytes from socket channel `ch`. Returns (received_fd, payload, err).

    +

    Receive one descriptor from socket `ch`, with the bytes that came with it (up to `max`): (fd, payload, ""), or (-1, "", err). The descriptor is close-on-exec. A message carrying no descriptor, more than one, or control data of another kind is an error, and every descriptor that did arrive is closed first.

    diff --git a/docs/emit-lib.md b/docs/emit-lib.md index 007bbad74..17be4eeb4 100644 --- a/docs/emit-lib.md +++ b/docs/emit-lib.md @@ -445,7 +445,8 @@ way you would treat any other cross-language value exchange. - `std.net`, `std.http` and its children, `std.tcp`, `std.udp` networking - `std.fs`, `std.file`, `std.dir` filesystem -- `std.os`, `std.dl` process, environment, shell, and dynamic loading +- `std.os`, `std.dl`, `std.ipc` process, environment, shell, dynamic + loading, and the parent channel (which can pass open descriptors) - `contrib.host.*` foreign-language runtimes The rationale: a library embedded in a host process shouldn't have @@ -473,7 +474,7 @@ untrusted user script, opt into specific capability categories: ```sh ae build --emit=lib --with=fs file.ae # std.fs, std.file, std.dir ae build --emit=lib --with=net file.ae # std.net/http/tcp/udp families -ae build --emit=lib --with=os file.ae # std.os, std.dl +ae build --emit=lib --with=os file.ae # std.os, std.dl, std.ipc ae build --emit=lib --with=fs,os file.ae # multiple, comma-separated ae build --emit=lib --with=extern file.ae # own C externs, contrib.host.* ae build --emit=lib --with=first-party file.ae # alias for fs,net,os,extern diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 1c8dcb032..b0bc55562 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -43,7 +43,7 @@ header comment is the authoritative description. | `std.intarr` | Fixed-size packed-int buffer. | 15 | [guide](../std/intarr/README.md) · [source](../std/intarr/module.ae) | | `std.intmap` | Hash map from `long` keys to `long` values, for integer-keyed counting and lookup. | 12 | [guide](../std/intmap/README.md) · [source](../std/intmap/module.ae) | | `std.io` | Console output, whole-file reads and writes, file descriptors, environment variables. | 46 | [full section](#io-stdio) | -| `std.ipc` | Child-to-parent back-channel for processes started by `std.os`. | 17 | [guide](../std/ipc/README.md) · [source](../std/ipc/module.ae) | +| `std.ipc` | The channel to a parent process started by `std.os`: a report pipe, or a two-way socket that passes descriptors. | 13 | [guide](../std/ipc/README.md) · [source](../std/ipc/module.ae) | | `std.json` | JSON parsing, building and serialisation. | 51 | [full section](#json-stdjson) | | `std.jsonpath` | RFC 9535 JSONPath queries over parsed JSON, with a reusable compiled path. | 10 | [guide](../std/jsonpath/README.md) · [source](../std/jsonpath/module.ae) | | `std.ksuid` | KSUID: 160-bit lexicographically sortable identifier. | 1 | [guide](../std/ksuid/README.md) · [source](../std/ksuid/module.ae) | @@ -3108,8 +3108,8 @@ spawned child exited 5 - `os.wait_pid_timeout(pid, secs)` → `(int, int, string)` - Wait at most `secs` for one child: status, `timed_out`, error. A child that times out is left running - `os.run_supervised(prog, argv, env, new_process_group, forward_signals, timeout_secs, reap_group)` → `(int, string)` - The whole job-control pattern in one call: the child in its own process group, Ctrl-C forwarded to it, a deadline (exit status 124, as GNU `timeout` reports), and anything it leaked cleaned up afterwards. The flags are 1 or 0; returns the exit status and an outcome of `"exited"`, `"signalled"`, `"timeout"` or `"error"`, or -1 and the reason when the child could not start - `os.run_pipe(prog, argv, env)` → `(int, int, string)`, `os.wait_pid(pid)` → `(int, string)`, `os.run_pipe_drain_and_wait(prog, argv, env)` → `(string, int, string)` - Spawn with the [`std.ipc`](#child-to-parent-reports-stdipc) back-channel (POSIX only). A program that cannot start is an `err` with no child to reap; an explicit `env` is the child's whole environment, with `AETHER_IPC_FD=3` in place of any it gives -- `os.run_socket(prog, argv, env)` → `(int, int, string)` - Spawn child with a bidirectional socket channel (`AF_UNIX`, `SOCK_STREAM`), with child descriptor at fd 3 and `AETHER_IPC_FD=3` and `AETHER_IPC_KIND=socket` in its environment -- `os.socketpair()` → `(int, int, string)` - Create a connected pair of Unix domain stream sockets with `O_CLOEXEC` set on both ends +- `os.run_socket(prog, argv, env)` → `(int, int, string)` - Spawn with a two-way [`std.ipc`](#child-to-parent-reports-stdipc) socket channel (POSIX only): the child's end of an `AF_UNIX` stream pair at fd 3, with `AETHER_IPC_FD=3` and `AETHER_IPC_KIND=socket`; returns the parent's end, close-on-exec, and the child to reap with `os.wait_pid` +- `os.socketpair()` → `(int, int, string)` - A connected `AF_UNIX` stream pair within one process, both ends close-on-exec (POSIX only) - `os_execv(prog, argv_list)` → `int` - Replace this process (on Windows: run it, then exit with its status); listed with the argv accessors above - `os.chdir(path)` → `string`, `os.getcwd()` → `string` - The working directory; `chdir` returns `""` or an error - `os_which(name)` → `string` - The first match for `name` on `PATH`, or `""` @@ -3157,8 +3157,12 @@ main() { **Functions:** - `ipc.parent_channel()` → `int` - The inherited back-channel descriptor, or -1 when there is none -- `ipc.write(fd, bytes)` → `string` - Write; `""` or an error +- `ipc.parent_channel_kind()` → `string` - `"pipe"` (from `os.run_pipe`), `"socket"` (from `os.run_socket`), or `""` when there is none +- `ipc.write(fd, bytes)` → `string` - Write every byte; `""` or an error. On a socket whose peer has gone, an error rather than SIGPIPE - `ipc.write_close(fd, bytes)` → `string` - Write and close, for the common one-report-at-exit shape +- `ipc.read(fd, max)` → `(string, string)` - One binary-safe read of up to `max` bytes; `("", "")` at end of stream +- `ipc.send_fd(ch, fd, payload)` → `string` - Pass an open descriptor over a socket channel, attached to a payload of at least one byte (POSIX only) +- `ipc.recv_fd(ch, max)` → `(int, string, string)` - Receive exactly one descriptor (close-on-exec) and its payload; a message with none, several, or other control data is an error, and every descriptor that arrived is closed (POSIX only) ### Signal numbers (`std.signal`) diff --git a/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md b/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md index 5d791293f..89ef01637 100644 --- a/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md +++ b/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md @@ -1 +1,20 @@ -- `std.ipc` gained a bidirectional socket channel and file descriptor passing on Linux, FreeBSD, and macOS. `os.run_socket` spawns children with a connected `AF_UNIX` stream socket pair, and `os.socketpair` creates in-process socket pairs. `std.ipc` gained `parent_channel_kind()`, binary-safe `read()`, `send_fd()`, and `recv_fd()`, while preserving `parent_channel()`, `write()`, and `write_close()` for backward-compatible pipe channels. Descriptor passing enforces strict safety rules: exactly one descriptor per message with leak prevention, `FD_CLOEXEC` on receipt, `SIGPIPE` resistance, short write retries, empty payload rejection, and descriptive error messages. +- **`std.ipc` gains a two-way socket channel that passes open descriptors.** + `os.run_socket(prog, argv, env)` spawns a child with one end of an + `AF_UNIX` stream pair at fd 3 (`AETHER_IPC_FD=3`, `AETHER_IPC_KIND=socket`), + through the same spawn path as `os.run_pipe`, and `os.socketpair()` makes a + pair within one process; both ends are close-on-exec. `std.ipc` adds + `parent_channel_kind()` (`"pipe"`, `"socket"` or `""`), a binary-safe + `read(ch, max)`, and `send_fd(ch, fd, payload)` / `recv_fd(ch, max)`. + `recv_fd` accepts exactly one descriptor per message: one with none, + several, truncated control data or another kind of control message is an + error, and every descriptor that arrived is closed, so a hostile peer + cannot fill the receiver's descriptor table. Received descriptors are + close-on-exec (`MSG_CMSG_CLOEXEC`; `fcntl` straight after on macOS). + `ipc.write` now writes every byte itself, continuing a short write and + retrying an interrupted one, and a socket whose peer has gone is an error + rather than SIGPIPE; it no longer goes through `std.net.fd_write`. + `parent_channel()`, `write_close`, `os.run_pipe` and + `os.run_pipe_drain_and_wait` are unchanged. Under `--emit=lib`, importing + `std.ipc` now needs `--with=os`, as `std.os` does. The socket channel and + descriptor passing are POSIX-only; on Windows they report that they are + unsupported. diff --git a/runtime/utils/aether_spawn.c b/runtime/utils/aether_spawn.c index 90fc263ef..bab722977 100644 --- a/runtime/utils/aether_spawn.c +++ b/runtime/utils/aether_spawn.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #ifdef __APPLE__ @@ -132,6 +133,30 @@ pid_t aether_fork(void) { return pid; #endif } + +/* std.os's socket channel (os.run_socket, os.socketpair). Every platform + * with pipe2 has SOCK_CLOEXEC too; macOS has neither and takes the lock as + * aether_pipe_cloexec does. */ +int aether_socketpair_cloexec(int fds[2]) { +#ifdef AETHER_HAVE_PIPE2 + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) return -1; +#else + pthread_rwlock_rdlock(&aether_fork_lock); + int rc = socketpair(AF_UNIX, SOCK_STREAM, 0, fds); + if (rc == 0) { + fcntl(fds[0], F_SETFD, FD_CLOEXEC); + fcntl(fds[1], F_SETFD, FD_CLOEXEC); + } + pthread_rwlock_unlock(&aether_fork_lock); + if (rc != 0) return -1; +#endif +#ifdef SO_NOSIGPIPE + int on = 1; + setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); + setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#endif + return 0; +} #endif #endif diff --git a/runtime/utils/aether_spawn.h b/runtime/utils/aether_spawn.h index c9c607e6b..fed60d9b0 100644 --- a/runtime/utils/aether_spawn.h +++ b/runtime/utils/aether_spawn.h @@ -38,6 +38,14 @@ int aether_pipe_cloexec(int fds[2]); pid_t aether_fork(void); +/* A connected AF_UNIX stream pair under the same rules as + * aether_pipe_cloexec: both ends close on exec (SOCK_CLOEXEC, or fcntl under + * the fork lock where that is missing), and on macOS and the BSDs both carry + * SO_NOSIGPIPE, so a write to a closed peer fails with EPIPE rather than + * raising SIGPIPE. Defined where aether_fork is. Returns 0, or -1 with errno + * set. */ +int aether_socketpair_cloexec(int fds[2]); + /* The program a child runs. A name with a '/' is taken as given. A bare * name is looked up on this process's PATH, whatever env the child is * given, as Windows and Go do: the first entry where it is a regular file diff --git a/std/ipc/README.md b/std/ipc/README.md index 8fee48631..090a3964b 100644 --- a/std/ipc/README.md +++ b/std/ipc/README.md @@ -1,53 +1,138 @@ # std.ipc -Inter-process communication between parent and child processes. +A channel to the parent process: a report pipe, or a two-way socket that +can also pass open file descriptors. -`std.ipc` provides child-side discovery and bidirectional communication over -channels inherited from `std.os` spawns, as well as file descriptor passing over -stream sockets on Linux, FreeBSD, and macOS. +When a program is spawned by `std.os`, it may inherit an extra descriptor +that its parent holds the other end of. `std.ipc` finds it and reads, +writes and passes descriptors over it, without colliding with stdout, +which the parent may be capturing for other reasons. -## Channel Kinds +## Two kinds of channel -A process spawned by `std.os` may inherit one of two channel kinds: +| Kind | Parent spawns with | Direction | Descriptor passing | +|---|---|---|---| +| `"pipe"` | `os.run_pipe`, `os.run_pipe_drain_and_wait` | child to parent | no | +| `"socket"` | `os.run_socket` | both ways | yes | -1. **`"pipe"`**: A one-way report channel created by `os.run_pipe` or - `os.run_pipe_drain_and_wait`. The child writes into this pipe to send - progress or structured reports without colliding with stdout/stderr. -2. **`"socket"`**: A two-way Unix domain stream socket pair created by - `os.run_socket`. Both parent and child can send and receive binary data and - pass open file descriptors across the boundary. +`parent_channel()` returns the inherited descriptor, or `-1` when there is +none; not being spawned with one is a normal outcome, not an error. +`parent_channel_kind()` says which kind it is: `"pipe"`, `"socket"`, or +`""` when there is none. -`parent_channel()` returns the inherited channel descriptor (negative when none -exists), preserving backward compatibility. `parent_channel_kind()` returns -`"pipe"`, `"socket"`, or `""` if no channel was inherited. +The example **compiles but is not run** in CI: it needs a parent that opened +the channel, and outside one `parent_channel` reports that there is none. -## Functions +```aether +import std.ipc -- `parent_channel() -> int`: Returns the inherited channel descriptor, or `-1` if none. -- `parent_channel_kind() -> string`: Returns `"pipe"`, `"socket"`, or `""`. -- `write(ch: int, data: string) -> string`: Write bytes to channel `ch`. Returns `""` on success or an error message. -- `write_close(ch: int, data: string) -> string`: Write bytes to channel `ch` and close the descriptor. -- `read(ch: int, max: int) -> (string, string)`: Binary-safe read up to `max` bytes. Returns `(data, err)`. On EOF, returns `("", "")`. -- `send_fd(ch: int, fd: int, payload: string) -> string`: Sends one open file descriptor with attached payload (at least 1 byte). Returns `""` on success or an error message. -- `recv_fd(ch: int, max: int) -> (int, string, string)`: Receives one open file descriptor and payload up to `max` bytes. Returns `(fd, payload, err)`. +main() { + ch = ipc.parent_channel() + if ch < 0 { + println("no parent channel") + return + } -## Safety Guarantees + werr = ipc.write(ch, "progress: 50%\n") + if werr != "" { + println("write failed: ${werr}") + } -When passing and receiving file descriptors via `send_fd` and `recv_fd`: + // write_close sends a final payload and closes, which is what + // signals the parent that no more is coming. + ipc.write_close(ch, "done\n") +} +``` -1. **Exactly one descriptor per message:** If control data is truncated (`MSG_CTRUNC`), or more than one descriptor is received, or an unexpected control message arrives, all received descriptors are closed immediately and an error is returned to prevent descriptor leakage. -2. **Close-on-exec at receipt:** Received descriptors have `FD_CLOEXEC` applied atomically upon receipt (`MSG_CMSG_CLOEXEC` on Linux and FreeBSD; immediate `fcntl` fallback on macOS). -3. **No SIGPIPE:** Socket operations use `MSG_NOSIGNAL` (Linux, FreeBSD) and `SO_NOSIGPIPE` (macOS). Writing to a closed peer returns an error instead of terminating the process with `SIGPIPE`. -4. **Partial writes and EINTR:** Writes and sends loop on partial writes and retry on `EINTR`. Success is never reported on a truncated write. -5. **No silent missing descriptors:** A message without ancillary control data arriving at `recv_fd` produces an explicit error, returning `fd = -1`. -6. **Descriptive error reasons:** Errors detail the failure cause and include OS `strerror(errno)` where appropriate. +`write_close(fd, bytes)` writes a last payload and closes in one call. That +close matters: a parent draining the channel blocks until it sees EOF, so a +child that exits without closing leaves the parent waiting until it notices +the process is gone. -## Platform Support +## The socket channel -- **Linux, FreeBSD, macOS:** Full support for pipe channels, socket channels, and file descriptor passing. -- **Windows:** Pipe back-channel and socket/fd passing APIs return clear "unsupported on Windows" errors. -- **WASM / Minimal builds:** Stubs returning "unavailable" link cleanly in all environments. +`os.run_socket(prog, argv, env)` spawns the child with one end of an +`AF_UNIX` stream socket pair at fd 3 (`AETHER_IPC_FD=3`, +`AETHER_IPC_KIND=socket`) and returns `(fd, pid, err)`: the parent's end, +close-on-exec, and the child to reap with `os.wait_pid`. +`os.socketpair()` makes a connected pair within one process. + +On either end: + +- `write(ch, data) -> err` writes every byte, or says why it could not. +- `read(ch, max) -> (data, err)` returns what one read brings, up to + `max` bytes: possibly less than was written, since the channel is a byte + stream and framing is the caller's. `("", "")` is end of stream. +- `send_fd(ch, fd, payload) -> err` passes the open descriptor `fd`, + attached to `payload`, which must be at least one byte. The receiver gets + its own descriptor for the same open file; the sender's stays open. +- `recv_fd(ch, max) -> (fd, payload, err)` receives one descriptor and the + bytes that came with it. + +A parent that has checked a request can open a file and hand the child the +open descriptor, so a child that cannot open files itself still reads it: + +```aether +import std.ipc +import std.io +import std.os +import std.list + +main() { + argv = list.new() + ch, child, err = os.run_socket("./worker", argv, null) + list.free(argv) + if err != "" { + println("spawn failed: ${err}") + return + } + f, ferr = io.fd_open_read("data.txt") + if ferr == "" { + ipc.send_fd(ch, f, "data.txt") + io.fd_close(f) + } + report, _ = ipc.read(ch, 4096) + println(report) + io.fd_close(ch) + os.wait_pid(child) +} +``` + +## What the receiver can rely on + +1. **Exactly one descriptor per message.** A message whose control data + was truncated, that carries more than one descriptor, or a control + message other than `SCM_RIGHTS` is an error, and every descriptor that + did arrive is closed first, so a hostile peer cannot fill the + receiver's descriptor table with messages it refuses. +2. **Close-on-exec at receipt.** On Linux and FreeBSD the descriptor is + created close-on-exec (`MSG_CMSG_CLOEXEC`). macOS lacks that and sets + it straight after receipt, so a `fork` on another thread in between can + copy the descriptor into that child. +3. **No SIGPIPE.** Writing to a socket whose peer has gone is an error, + not the end of the process (`MSG_NOSIGNAL`; `SO_NOSIGPIPE` on macOS, + set on both ends `std.os` makes). +4. **Whole writes.** Short writes continue and interrupted ones retry; + success means every byte went. +5. **No silent missing descriptor.** A message without one is an error + saying so, never a `-1` alongside an empty error. +6. **Reasons.** Errors name the call and the cause, with the system's + message where there is one. + +## Platforms + +- **Linux, FreeBSD, macOS:** both kinds of channel and descriptor passing. +- **Windows:** no parent channel (`parent_channel()` is `-1`); `write` and + `read` are plain descriptor I/O; `os.run_socket`, `os.socketpair`, + `send_fd` and `recv_fd` report that they are unsupported. Windows passes + handles with `DuplicateHandle`, a different shape. +- **Builds without a filesystem** (WASM, embedded): every function + exists and reports that it is unavailable. + +Under `--emit=lib`, importing `std.ipc` needs `--with=os`, as `std.os` +does: passing descriptors hands out authority. ## Exports -`parent_channel`, `parent_channel_kind`, `write`, `write_close`, `read`, `send_fd`, `recv_fd`. +`parent_channel`, `parent_channel_kind`, `write`, `write_close`, `read`, +`send_fd`, `recv_fd`. diff --git a/std/ipc/aether_ipc.c b/std/ipc/aether_ipc.c index 1a3fe5ace..6a16d354f 100644 --- a/std/ipc/aether_ipc.c +++ b/std/ipc/aether_ipc.c @@ -1,466 +1,427 @@ -/* aether_ipc.c — child-side IPC primitives, two-way socket channels, and descriptor passing. */ +/* aether_ipc.c — std.ipc: a process's channel to its parent, and the socket + * channel's reads and descriptor passing. + * + * Companion to std.os.run_pipe and std.os.run_socket (parent side). + * Provides the function the child calls to discover the channel fd that + * the parent opened, and the reads, writes and descriptor passing either + * end uses on it. + * + * Mechanism: the parent sets AETHER_IPC_FD= in the child's + * environment before exec, and dup2's its end of the channel to + * fd . Child reads getenv("AETHER_IPC_FD"), parses to int, + * verifies the fd is open. Returns fd or -1. os.run_socket also sets + * AETHER_IPC_KIND=socket; a channel without it is a pipe. + * + * The env-var-as-primary design (rather than hardcoded fd 3) + * sidesteps fd-allocation surprises in shell intermediaries + * (e.g. `bash -c ''` between aeb and the test driver). + * + * Descriptor passing (send_fd / recv_fd) is SCM_RIGHTS on the socket + * channel. The receiver is the side a hostile peer attacks, so recv_fd + * accepts exactly one descriptor per message and closes every one that + * arrived when it refuses a message: otherwise a peer could fill the + * receiver's descriptor table with messages it rejects. */ #include "../../runtime/config/aether_optimization_config.h" +#include +#include #include #include -#include #include "../string/aether_string.h" typedef struct { const char* _0; const char* _1; } _tuple_string_string; typedef struct { int _0; const char* _1; const char* _2; } _tuple_int_string_string; +#if AETHER_HAS_FILESYSTEM +/* The bytes of a string argument: an AetherString carries its length, so a + * payload with a zero byte in it goes whole; a plain char* goes to its NUL. */ +static const char* ipc_bytes(const void* obj, size_t* len) { + if (!obj) { + *len = 0; + return ""; + } + if (aether_string_is_string(obj)) { + const AetherString* s = (const AetherString*)obj; + *len = s->length; + return s->data; + } + *len = strlen((const char*)obj); + return (const char*)obj; +} +#endif + +static _tuple_string_string ipc_read_failed(const char* err) { + _tuple_string_string out = { (const char*)string_empty(), err }; + return out; +} + +static _tuple_int_string_string ipc_recv_failed(const char* err) { + _tuple_int_string_string out = { -1, (const char*)string_empty(), err }; + return out; +} + #if !AETHER_HAS_FILESYSTEM int ipc_parent_channel_raw(void) { return -1; } const char* ipc_parent_channel_kind_raw(void) { return ""; } -const char* ipc_write_raw(int fd, const void* bytes_obj) { (void)fd; (void)bytes_obj; return "ipc.write unavailable"; } +const char* ipc_write_raw(int fd, const void* bytes) { + (void)fd; (void)bytes; + return "ipc.write: unavailable in this build"; +} _tuple_string_string ipc_read_raw(int fd, int max) { (void)fd; (void)max; - _tuple_string_string out = { (const char*)string_empty(), "ipc.read unavailable" }; - return out; + return ipc_read_failed("ipc.read: unavailable in this build"); } -const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { - (void)ch; (void)fd; (void)payload_obj; - return "ipc.send_fd unavailable"; +const char* ipc_send_fd_raw(int ch, int fd, const void* payload) { + (void)ch; (void)fd; (void)payload; + return "ipc.send_fd: unavailable in this build"; } _tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { (void)ch; (void)max; - _tuple_int_string_string out = { -1, (const char*)string_empty(), "ipc.recv_fd unavailable" }; - return out; + return ipc_recv_failed("ipc.recv_fd: unavailable in this build"); } -int ipc_set_sock_buf_raw(int fd, int size) { (void)fd; (void)size; return -1; } -int ipc_get_cloexec_raw(int fd) { (void)fd; return -1; } -int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { (void)ch; (void)fd1; (void)fd2; (void)payload_obj; return -1; } -int ipc_test_send_async_raw(int fd, const void* payload_obj) { (void)fd; (void)payload_obj; return -1; } -#else /* AETHER_HAS_FILESYSTEM */ +#elif !defined(_WIN32) -#ifndef _WIN32 - -#include #include +#include +#include + +/* Sockets, and so the socket channel and descriptor passing, where there + * are processes to talk between. WASI has a filesystem and descriptors but + * neither: write and read stay plain descriptor I/O there. */ +#if AETHER_HAS_PROCESS && !defined(__wasi__) +#define IPC_HAS_SOCKETS 1 #include -#include -#include +#else +#define IPC_HAS_SOCKETS 0 +#endif + +/* Linux and FreeBSD refuse SIGPIPE per call; macOS has no MSG_NOSIGNAL and + * carries it on the socket instead (SO_NOSIGPIPE, which os.socketpair and + * os.run_socket set on both ends, and which a child's inherited end keeps). */ +#if IPC_HAS_SOCKETS && defined(MSG_NOSIGNAL) +#define IPC_NOSIGNAL MSG_NOSIGNAL +#else +#define IPC_NOSIGNAL 0 +#endif + +/* The most descriptors one recvmsg makes room for. A message carrying more + * is truncated by the kernel (MSG_CTRUNC), which closes what did not fit; + * recv_fd closes the rest. Room for more than one is what lets recv_fd see + * a second descriptor and refuse it, rather than only a truncation. */ +#define IPC_MAX_FDS 8 + +/* ": " in a per-thread buffer, valid until this + * thread's next failing call. */ +static const char* ipc_errno_msg(const char* what, int err) { + static __thread char msg[256]; + char buf[128]; + const char* reason = buf; + buf[0] = '\0'; +#if defined(__GLIBC__) && defined(_GNU_SOURCE) + reason = strerror_r(err, buf, sizeof buf); +#else + if (strerror_r(err, buf, sizeof buf) != 0) snprintf(buf, sizeof buf, "error %d", err); +#endif + snprintf(msg, sizeof msg, "%s: %s", what, reason); + return msg; +} int ipc_parent_channel_raw(void) { const char* s = getenv("AETHER_IPC_FD"); - if (!s || !s[0]) return -1; + if (!s || !*s) return -1; char* end = NULL; long v = strtol(s, &end, 10); if (end == s || *end != '\0') return -1; if (v < 0 || v > 65535) return -1; int fd = (int)v; + /* Verify the fd is open. fcntl(F_GETFD) returns -1 with + * errno=EBADF if not. */ if (fcntl(fd, F_GETFD) < 0) return -1; return fd; } const char* ipc_parent_channel_kind_raw(void) { - int fd = ipc_parent_channel_raw(); - if (fd < 0) return ""; + if (ipc_parent_channel_raw() < 0) return ""; const char* k = getenv("AETHER_IPC_KIND"); if (k && strcmp(k, "socket") == 0) return "socket"; return "pipe"; } -static const char* extract_bytes(const void* obj, size_t* len) { - if (!obj) { - *len = 0; - return ""; - } - if (aether_string_is_string(obj)) { - AetherString* s = (AetherString*)obj; - *len = s->length; - return s->data; - } - *len = strlen((const char*)obj); - return (const char*)obj; -} - -const char* ipc_write_raw(int fd, const void* bytes_obj) { - if (fd < 0) return "ipc.write: invalid file descriptor"; - size_t len = 0; - const char* data = extract_bytes(bytes_obj, &len); - - size_t total = 0; - while (total < len) { - ssize_t n = send(fd, data + total, len - total, MSG_NOSIGNAL); +/* Write all `len` bytes: short writes continue where they stopped and EINTR + * retries, so success always means every byte went. A socket is written with + * send(MSG_NOSIGNAL), so a gone peer is EPIPE rather than SIGPIPE; a pipe + * (send says ENOTSOCK) with write(), as std.net's fd_write does. */ +static const char* ipc_write_all(int fd, const char* data, size_t len, const char* what) { + int is_socket = IPC_HAS_SOCKETS; + size_t done = 0; + while (done < len) { +#if IPC_HAS_SOCKETS + ssize_t n = is_socket ? send(fd, data + done, len - done, IPC_NOSIGNAL) + : write(fd, data + done, len - done); +#else + ssize_t n = write(fd, data + done, len - done); +#endif if (n < 0) { if (errno == EINTR) continue; - if (errno == ENOTSOCK) { - /* Fallback to write for plain pipes */ - n = write(fd, data + total, len - total); - if (n < 0) { - if (errno == EINTR) continue; - static char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "ipc.write: write failed: %s", strerror(errno)); - return errbuf; - } - } else { - static char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "ipc.write: send failed: %s", strerror(errno)); - return errbuf; + if (is_socket && errno == ENOTSOCK) { + is_socket = 0; + continue; } + return ipc_errno_msg(what, errno); } - if (n == 0) { - return "ipc.write: short write (0 bytes written)"; - } - total += (size_t)n; + if (n == 0) return ipc_errno_msg(what, EIO); + done += (size_t)n; } return ""; } -_tuple_string_string ipc_read_raw(int fd, int max) { - _tuple_string_string out; - out._0 = NULL; - out._1 = ""; - - if (fd < 0) { - out._0 = (const char*)string_empty(); - out._1 = "ipc.read: invalid file descriptor"; - return out; - } - if (max <= 0) { - out._0 = (const char*)string_empty(); - out._1 = "ipc.read: max bytes must be > 0"; - return out; - } +const char* ipc_write_raw(int fd, const void* bytes) { + if (fd < 0) return "ipc.write: invalid descriptor"; + size_t len = 0; + const char* data = ipc_bytes(bytes, &len); + return ipc_write_all(fd, data, len, "ipc.write"); +} +/* One read of up to `max` bytes: what arrived, ("", "") at end of stream, + * or ("", err). Binary-safe: the string carries its length. */ +_tuple_string_string ipc_read_raw(int fd, int max) { + if (fd < 0) return ipc_read_failed("ipc.read: invalid descriptor"); + if (max <= 0) return ipc_read_failed("ipc.read: max must be positive"); char* buf = (char*)malloc((size_t)max); - if (!buf) { - out._0 = (const char*)string_empty(); - out._1 = "ipc.read: out of memory"; - return out; - } - - ssize_t n = 0; - while (1) { - n = read(fd, buf, (size_t)max); - if (n < 0) { - if (errno == EINTR) continue; - free(buf); - static char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "ipc.read: read failed: %s", strerror(errno)); - out._0 = (const char*)string_empty(); - out._1 = errbuf; - return out; - } - break; - } - - if (n == 0) { - /* EOF — return empty string, empty error */ + if (!buf) return ipc_read_failed("ipc.read: out of memory"); + ssize_t n; + do { n = read(fd, buf, (size_t)max); } while (n < 0 && errno == EINTR); + if (n < 0) { + int e = errno; free(buf); - out._0 = (const char*)string_empty(); - out._1 = ""; - return out; + return ipc_read_failed(ipc_errno_msg("ipc.read", e)); } - - AetherString* s = (AetherString*)string_new_with_length(buf, (size_t)n); + AetherString* s = string_new_with_length(buf, (size_t)n); free(buf); - if (!s) { - out._0 = (const char*)string_empty(); - out._1 = "ipc.read: string allocation failed"; - return out; - } - - out._0 = (const char*)s; - out._1 = ""; + if (!s) return ipc_read_failed("ipc.read: out of memory"); + _tuple_string_string out = { (const char*)s, "" }; return out; } -const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { - if (ch < 0) return "ipc.send_fd: invalid channel descriptor"; - if (fd < 0) return "ipc.send_fd: invalid file descriptor to send"; - - size_t payload_len = 0; - const char* payload = extract_bytes(payload_obj, &payload_len); - - if (payload_len == 0) { - return "ipc.send_fd: empty payload is refused"; +#if IPC_HAS_SOCKETS +/* Send `fd` with `payload` attached. The descriptor travels with the first + * byte of the payload, so a sendmsg that sends only part of it has already + * sent the descriptor, and the rest goes as plain bytes. */ +const char* ipc_send_fd_raw(int ch, int fd, const void* payload) { + if (ch < 0) return "ipc.send_fd: invalid channel"; + if (fd < 0) return "ipc.send_fd: invalid descriptor to send"; + size_t len = 0; + const char* data = ipc_bytes(payload, &len); + if (len == 0) { + return "ipc.send_fd: empty payload (a descriptor travels attached to at least one byte)"; } - struct msghdr msg; - memset(&msg, 0, sizeof(msg)); - struct iovec iov; - iov.iov_base = (void*)payload; - iov.iov_len = payload_len; + iov.iov_base = (void*)data; + iov.iov_len = len; + union { + char buf[CMSG_SPACE(sizeof(int))]; + struct cmsghdr align; + } control; + memset(&control, 0, sizeof control); + struct msghdr msg; + memset(&msg, 0, sizeof msg); msg.msg_iov = &iov; msg.msg_iovlen = 1; - - char control_buf[CMSG_SPACE(sizeof(int))]; - memset(control_buf, 0, sizeof(control_buf)); - msg.msg_control = control_buf; - msg.msg_controllen = sizeof(control_buf); - + msg.msg_control = control.buf; + msg.msg_controllen = sizeof control.buf; struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); - cmsg->cmsg_len = CMSG_LEN(sizeof(int)); cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); - while (1) { - ssize_t n = sendmsg(ch, &msg, MSG_NOSIGNAL); - if (n < 0) { - if (errno == EINTR) continue; - static char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "ipc.send_fd: sendmsg failed: %s", strerror(errno)); - return errbuf; - } - if ((size_t)n < payload_len) { - return "ipc.send_fd: partial write during sendmsg"; - } - break; - } - + ssize_t n; + do { n = sendmsg(ch, &msg, IPC_NOSIGNAL); } while (n < 0 && errno == EINTR); + if (n < 0) return ipc_errno_msg("ipc.send_fd", errno); + if ((size_t)n < len) return ipc_write_all(ch, data + n, len - (size_t)n, "ipc.send_fd"); return ""; } -_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { - _tuple_int_string_string out; - out._0 = -1; - out._1 = NULL; - out._2 = ""; - - if (ch < 0) { - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: invalid channel descriptor"; - return out; - } - if (max <= 0) { - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: max bytes must be > 0"; - return out; - } - - char* payload_buf = (char*)malloc((size_t)max); - if (!payload_buf) { - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: out of memory"; - return out; - } +static void ipc_close_all(const int* fds, size_t n) { + for (size_t i = 0; i < n; i++) close(fds[i]); +} - struct msghdr msg; - memset(&msg, 0, sizeof(msg)); +/* Receive one descriptor and the bytes that came with it (up to `max`). + * + * The channel is a byte stream, so the bytes are whatever one recvmsg + * returns: the payload sent with the descriptor, and possibly bytes written + * after it. Framing is the caller's. + * + * Exactly one descriptor, in one SCM_RIGHTS message, or an error: on a + * truncated control buffer, a control message of another kind, more than + * one descriptor, or none, every descriptor that arrived is closed first. + * + * Close-on-exec at receipt: MSG_CMSG_CLOEXEC sets it as the descriptor is + * made (Linux, FreeBSD). macOS lacks it and gets fcntl straight after + * recvmsg, which leaves a window: a fork on another thread between the two + * copies the descriptor into that child, and an exec there keeps it. That + * is the race aether_pipe_cloexec closes with the fork lock; holding the + * lock across a recvmsg that may block would stall every fork, so here it + * stays open. */ +_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { + if (ch < 0) return ipc_recv_failed("ipc.recv_fd: invalid channel"); + if (max <= 0) return ipc_recv_failed("ipc.recv_fd: max must be positive"); + char* buf = (char*)malloc((size_t)max); + if (!buf) return ipc_recv_failed("ipc.recv_fd: out of memory"); struct iovec iov; - iov.iov_base = payload_buf; + iov.iov_base = buf; iov.iov_len = (size_t)max; + union { + char buf[CMSG_SPACE(sizeof(int) * IPC_MAX_FDS)]; + struct cmsghdr align; + } control; + memset(&control, 0, sizeof control); + struct msghdr msg; + memset(&msg, 0, sizeof msg); msg.msg_iov = &iov; msg.msg_iovlen = 1; + msg.msg_control = control.buf; + msg.msg_controllen = sizeof control.buf; - /* Allow buffer for up to 8 descriptors to detect multi-descriptor abuse */ - char control_buf[CMSG_SPACE(sizeof(int) * 8)]; - memset(control_buf, 0, sizeof(control_buf)); - msg.msg_control = control_buf; - msg.msg_controllen = sizeof(control_buf); - - int recv_flags = 0; + int flags = 0; #ifdef MSG_CMSG_CLOEXEC - recv_flags |= MSG_CMSG_CLOEXEC; + flags |= MSG_CMSG_CLOEXEC; #endif - - ssize_t n = 0; - while (1) { - n = recvmsg(ch, &msg, recv_flags); - if (n < 0) { - if (errno == EINTR) continue; - free(payload_buf); - static char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "ipc.recv_fd: recvmsg failed: %s", strerror(errno)); - out._1 = (const char*)string_empty(); - out._2 = errbuf; - return out; - } - break; - } - - if (n == 0) { - free(payload_buf); - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: end of stream (peer closed)"; - return out; - } - - /* Inspect control data */ - int received_fds[16]; - size_t num_fds = 0; - int has_bad_cmsg = 0; - - if (msg.msg_flags & MSG_CTRUNC) { - has_bad_cmsg = 1; + ssize_t n; + do { n = recvmsg(ch, &msg, flags); } while (n < 0 && errno == EINTR); + if (n < 0) { + int e = errno; + free(buf); + return ipc_recv_failed(ipc_errno_msg("ipc.recv_fd", e)); } - for (struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg)) { - if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) { - has_bad_cmsg = 1; + /* Collect every descriptor that arrived before judging the message, so + * a refusal can close them all. */ + int fds[IPC_MAX_FDS]; + size_t nfds = 0; + int foreign = 0; + for (struct cmsghdr* c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) { + if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) { + foreign = 1; continue; } - size_t fd_bytes = cmsg->cmsg_len - CMSG_LEN(0); - size_t fds_in_cmsg = fd_bytes / sizeof(int); - int* fds_ptr = (int*)(void*)CMSG_DATA(cmsg); - for (size_t i = 0; i < fds_in_cmsg; i++) { - if (num_fds < 16) { - received_fds[num_fds++] = fds_ptr[i]; - } else { - close(fds_ptr[i]); - } + size_t count = (c->cmsg_len - CMSG_LEN(0)) / sizeof(int); + for (size_t i = 0; i < count; i++) { + int fd; + memcpy(&fd, CMSG_DATA(c) + i * sizeof(int), sizeof fd); + if (nfds < IPC_MAX_FDS) fds[nfds++] = fd; + else close(fd); } } - if (has_bad_cmsg || (msg.msg_flags & MSG_CTRUNC)) { - for (size_t i = 0; i < num_fds; i++) { - close(received_fds[i]); - } - free(payload_buf); - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: control message truncated or invalid"; - return out; - } - - if (num_fds == 0) { - free(payload_buf); - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: message carried no file descriptor"; - return out; + const char* err = NULL; + if (msg.msg_flags & MSG_CTRUNC) { + err = "ipc.recv_fd: control data truncated (too many descriptors in one message)"; + } else if (foreign) { + err = "ipc.recv_fd: a control message other than SCM_RIGHTS"; + } else if (nfds > 1) { + err = "ipc.recv_fd: more than one descriptor in one message"; + } else if (nfds == 0) { + err = n == 0 ? "ipc.recv_fd: end of stream (peer closed)" + : "ipc.recv_fd: message carried no descriptor"; } - - if (num_fds > 1) { - for (size_t i = 0; i < num_fds; i++) { - close(received_fds[i]); - } - free(payload_buf); - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: more than one descriptor in one message"; - return out; + if (err) { + ipc_close_all(fds, nfds); + free(buf); + return ipc_recv_failed(err); } - int recvd_fd = received_fds[0]; - - /* Fallback set FD_CLOEXEC for systems lacking MSG_CMSG_CLOEXEC (e.g. macOS) */ - (void)fcntl(recvd_fd, F_SETFD, FD_CLOEXEC); - - AetherString* s = (AetherString*)string_new_with_length(payload_buf, (size_t)n); - free(payload_buf); - +#ifndef MSG_CMSG_CLOEXEC + fcntl(fds[0], F_SETFD, FD_CLOEXEC); +#endif + AetherString* s = string_new_with_length(buf, (size_t)n); + free(buf); if (!s) { - close(recvd_fd); - out._1 = (const char*)string_empty(); - out._2 = "ipc.recv_fd: payload string allocation failed"; - return out; + close(fds[0]); + return ipc_recv_failed("ipc.recv_fd: out of memory"); } - - out._0 = recvd_fd; - out._1 = (const char*)s; - out._2 = ""; + _tuple_int_string_string out = { fds[0], (const char*)s, "" }; return out; } - -int ipc_set_sock_buf_raw(int fd, int size) { - if (fd < 0 || size <= 0) return -1; - setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); - setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); - return 0; +#else +const char* ipc_send_fd_raw(int ch, int fd, const void* payload) { + (void)ch; (void)fd; (void)payload; + return "ipc.send_fd: unavailable in this build (no sockets)"; } - -int ipc_get_cloexec_raw(int fd) { - if (fd < 0) return -1; - int flags = fcntl(fd, F_GETFD); - if (flags < 0) return -1; - return (flags & FD_CLOEXEC) ? 1 : 0; +_tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { + (void)ch; (void)max; + return ipc_recv_failed("ipc.recv_fd: unavailable in this build (no sockets)"); } +#endif /* IPC_HAS_SOCKETS */ -int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { - if (ch < 0 || fd1 < 0 || fd2 < 0) return -1; - size_t payload_len = 0; - const char* payload = extract_bytes(payload_obj, &payload_len); - if (payload_len == 0) payload = "x", payload_len = 1; +#else /* _WIN32 */ - struct msghdr msg; - memset(&msg, 0, sizeof(msg)); - - struct iovec iov; - iov.iov_base = (void*)payload; - iov.iov_len = payload_len; - msg.msg_iov = &iov; - msg.msg_iovlen = 1; - - char control_buf[CMSG_SPACE(sizeof(int) * 2)]; - memset(control_buf, 0, sizeof(control_buf)); - msg.msg_control = control_buf; - msg.msg_controllen = sizeof(control_buf); - - struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); - cmsg->cmsg_len = CMSG_LEN(sizeof(int) * 2); - cmsg->cmsg_level = SOL_SOCKET; - cmsg->cmsg_type = SCM_RIGHTS; - int fds[2] = { fd1, fd2 }; - memcpy(CMSG_DATA(cmsg), fds, sizeof(fds)); +#include - ssize_t n = sendmsg(ch, &msg, MSG_NOSIGNAL); - return (n > 0) ? 0 : -1; +int ipc_parent_channel_raw(void) { + /* Windows handle inheritance for fd-mapped pipes is + * non-trivial (STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_HANDLE_LIST + * + _open_osfhandle on both sides). Even if AETHER_IPC_FD is + * set, there's no portable way to verify the descriptor is + * open as a pipe via the Win32 CRT layer. v1 ships POSIX-only; + * Windows consumers fall back to file-marker patterns. */ + return -1; } -typedef struct { - int fd; - AetherString* payload; -} AsyncSendArgs; - -static void* async_send_thread(void* arg) { - AsyncSendArgs* args = (AsyncSendArgs*)arg; - ipc_write_raw(args->fd, args->payload); - if (args->payload) string_free(args->payload); - free(args); - return NULL; -} +const char* ipc_parent_channel_kind_raw(void) { return ""; } -int ipc_test_send_async_raw(int fd, const void* payload_obj) { - if (fd < 0) return -1; +/* write and read stay plain CRT descriptor I/O here, as std.net's fd_write + * is; the socket channel and descriptor passing are POSIX-only (Windows + * passes handles with DuplicateHandle, a different shape). */ +const char* ipc_write_raw(int fd, const void* bytes) { + if (fd < 0) return "ipc.write: invalid descriptor"; size_t len = 0; - const char* data = extract_bytes(payload_obj, &len); - AetherString* copy_str = (AetherString*)string_new_with_length(data, len); - - AsyncSendArgs* args = (AsyncSendArgs*)malloc(sizeof(AsyncSendArgs)); - if (!args) return -1; - args->fd = fd; - args->payload = copy_str; - - pthread_t th; - if (pthread_create(&th, NULL, async_send_thread, args) != 0) { - free(args); - return -1; + const char* data = ipc_bytes(bytes, &len); + size_t done = 0; + while (done < len) { + unsigned chunk = (len - done) > 0x7fffffffu ? 0x7fffffffu : (unsigned)(len - done); + int n = _write(fd, data + done, chunk); + if (n < 0) { + if (errno == EINTR) continue; + return "ipc.write: write failed"; + } + if (n == 0) return "ipc.write: write failed"; + done += (size_t)n; } - pthread_detach(th); - return 0; + return ""; } -#else /* _WIN32 */ - -int ipc_parent_channel_raw(void) { return -1; } -const char* ipc_parent_channel_kind_raw(void) { return ""; } -const char* ipc_write_raw(int fd, const void* bytes_obj) { (void)fd; (void)bytes_obj; return "ipc.write unsupported on Windows"; } _tuple_string_string ipc_read_raw(int fd, int max) { - (void)fd; (void)max; - _tuple_string_string out = { (const char*)string_empty(), "ipc.read unsupported on Windows" }; + if (fd < 0) return ipc_read_failed("ipc.read: invalid descriptor"); + if (max <= 0) return ipc_read_failed("ipc.read: max must be positive"); + char* buf = (char*)malloc((size_t)max); + if (!buf) return ipc_read_failed("ipc.read: out of memory"); + int n = _read(fd, buf, (unsigned)max); + if (n < 0) { + free(buf); + return ipc_read_failed("ipc.read: read failed"); + } + AetherString* s = string_new_with_length(buf, (size_t)n); + free(buf); + if (!s) return ipc_read_failed("ipc.read: out of memory"); + _tuple_string_string out = { (const char*)s, "" }; return out; } -const char* ipc_send_fd_raw(int ch, int fd, const void* payload_obj) { - (void)ch; (void)fd; (void)payload_obj; - return "ipc.send_fd unsupported on Windows"; + +const char* ipc_send_fd_raw(int ch, int fd, const void* payload) { + (void)ch; (void)fd; (void)payload; + return "ipc.send_fd: unsupported on Windows"; } + _tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { (void)ch; (void)max; - _tuple_int_string_string out = { -1, (const char*)string_empty(), "ipc.recv_fd unsupported on Windows" }; - return out; + return ipc_recv_failed("ipc.recv_fd: unsupported on Windows"); } -int ipc_set_sock_buf_raw(int fd, int size) { (void)fd; (void)size; return -1; } -int ipc_get_cloexec_raw(int fd) { (void)fd; return -1; } -int test_ipc_send_two_fds(int ch, int fd1, int fd2, const void* payload_obj) { (void)ch; (void)fd1; (void)fd2; (void)payload_obj; return -1; } -int ipc_test_send_async_raw(int fd, const void* payload_obj) { (void)fd; (void)payload_obj; return -1; } - -#endif /* _WIN32 */ -#endif /* AETHER_HAS_FILESYSTEM */ +#endif /* AETHER_HAS_FILESYSTEM / _WIN32 */ diff --git a/std/ipc/module.ae b/std/ipc/module.ae index b513388c6..150a65bb9 100644 --- a/std/ipc/module.ae +++ b/std/ipc/module.ae @@ -1,78 +1,128 @@ -// std.ipc — child-to-parent and two-way channel IPC with descriptor passing. +// std.ipc — a process's channel to its parent: a report pipe, or a two-way +// socket that can also pass open file descriptors. // -// Companion to std.os.run_pipe and std.os.run_socket (parent side). When -// a parent spawns a child via std.os, the child can use std.ipc.parent_channel() -// to find the inherited channel descriptor. +// Companion to std.os.run_pipe and std.os.run_socket (parent side). +// When a parent spawns a child via os.run_pipe / +// os.run_pipe_drain_and_wait / os.run_socket, the child can use +// std.ipc.parent_channel() to find the fd of the inherited channel, +// and parent_channel_kind() to learn which kind it is. // -// Supports two channel types: -// - "pipe": write-only report pipe created by os.run_pipe / os.run_pipe_drain_and_wait. -// - "socket": bidirectional Unix domain socket created by os.run_socket. +// Models the brief in +// `~/scm/aetherBuild/asks/lightweight-ipc-dx.md` (DX shape) + +// `lightweight-ipc-dx-aether-reply.md` (proposed surface) + +// `lightweight-ipc-dx-aeb-final.md` (agreement). // -// Functions: -// parent_channel() -> int Find channel fd or -1 -// parent_channel_kind() -> string "pipe", "socket", or "" -// write(fd, bytes) -> string Write bytes; "" or err -// write_close(fd, bytes) -> string Write bytes + close; "" or err -// read(fd, max) -> (data, err) Read up to max bytes; "" data and "" err on EOF -// send_fd(ch, fd, payload) -> string Send 1 descriptor + >=1 byte payload -// recv_fd(ch, max) -> (fd, payload, err) Receive 1 descriptor + payload +// Primitives: +// +// parent_channel() -> int find fd or -1 +// parent_channel_kind() -> string "pipe", "socket" or "" +// write(fd, bytes) -> string write all; "" or err +// write_close(fd, bytes) -> string write + close; "" or err +// read(fd, max) -> (data, err) one read; ("", "") at EOF +// send_fd(ch, fd, payload) -> string pass a descriptor; "" or err +// recv_fd(ch, max) -> (fd, payload, err) receive one descriptor +// +// (write/write_close were originally named `send`/`send_close` per the +// design discussion; renamed because `send` is a reserved keyword in +// Aether for actor message-passing. `write` reads correctly here — the +// channel IS a writeable file descriptor under the hood.) +// +// read, send_fd and recv_fd work on any connected AF_UNIX stream socket, +// the parent's end from os.run_socket and both ends of os.socketpair +// included, not only on parent_channel(). send_fd / recv_fd are POSIX-only; +// on Windows they, and parent_channel(), report that there is none. +// +// Common shape — child writes one report at exit: +// +// import std.ipc +// +// ch = ipc.parent_channel() +// if ch >= 0 { +// err = ipc.write_close(ch, "passed=44\nfailed=3\n") +// } -import std.net +import std.io exports( ipc_parent_channel_raw, ipc_parent_channel_kind_raw, ipc_write_raw, ipc_read_raw, ipc_send_fd_raw, ipc_recv_fd_raw, - ipc_set_sock_buf_raw, ipc_get_cloexec_raw, test_ipc_send_two_fds, ipc_test_send_async_raw, parent_channel, parent_channel_kind, write, write_close, read, send_fd, recv_fd ) +// Returns the parent-channel fd if the parent spawned this process +// via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env +// and the named fd is open); -1 otherwise. Children of os.run / +// os.run_capture see -1 — those don't open a back-channel. On Windows +// always -1 (v1 stub). The fd value is whatever the parent picked; +// current parent-side default is 3 but consumers MUST NOT hardcode that. extern ipc_parent_channel_raw() -> int extern ipc_parent_channel_kind_raw() -> string extern ipc_write_raw(fd: int, bytes: @aether string) -> string extern ipc_read_raw(fd: int, max: int) -> (string @heap, string) extern ipc_send_fd_raw(ch: int, fd: int, payload: @aether string) -> string extern ipc_recv_fd_raw(ch: int, max: int) -> (int, string @heap, string) -extern ipc_set_sock_buf_raw(fd: int, size: int) -> int -extern ipc_get_cloexec_raw(fd: int) -> int -extern test_ipc_send_two_fds(ch: int, fd1: int, fd2: int, payload: string) -> int -extern ipc_test_send_async_raw(fd: int, payload: string) -> int -// Returns the parent-channel fd if the parent spawned this process -// via os.run_pipe or os.run_socket (i.e. AETHER_IPC_FD is set in env -// and the named fd is open); -1 otherwise. +// Aether-side wrappers. parent_channel returns the raw int from +// the extern; -1 sentinel for "no parent channel." parent_channel() -> int { return ipc_parent_channel_raw() } -// Returns the channel kind: "pipe" for a pipe channel, "socket" for a socket -// channel, or "" when no channel exists. +// "socket" for a channel from os.run_socket (two-way, can pass +// descriptors), "pipe" for one from os.run_pipe (write-only from the +// child), "" when parent_channel() finds none. parent_channel_kind() -> string { return ipc_parent_channel_kind_raw() } -// Write `bytes` to `fd`. Returns "" on success, non-empty error string -// on failure. Loops on short writes and handles SIGPIPE / EINTR. +// Write all of `bytes` to `fd`. Returns "" on success, non-empty +// error string on failure. A short write continues where it stopped +// and an interrupted one retries, so "" means every byte went; a +// string with a zero byte in it goes whole. On a socket whose peer has +// gone this is an error, not SIGPIPE. +// +// For the common case where the child writes one payload and +// exits, prefer write_close so the parent's read_all loop sees +// EOF promptly. write(fd: int, bytes: string) -> string { return ipc_write_raw(fd, bytes) } -// Write `bytes` then close the channel. +// Write `bytes` then close the channel. The common case: child +// produces one structured report at exit and closes the fd so +// the parent's drain loop sees EOF and stops. +// +// Calling close releases the writer's reference on the pipe; the +// parent sees EOF only after every writer has closed. If the +// child is itself going to exit immediately after, the kernel +// will close the fd anyway during process teardown — explicit +// close is still preferred because it lets the parent's reader +// unblock before the child exits, useful when the parent's +// reader runs in the same thread as the child-wait loop. write_close(fd: int, bytes: string) -> string { werr = write(fd, bytes) - net.fd_close(fd) + io.fd_close(fd) return werr } -// Binary-safe read of up to `max` bytes from `fd`. Returns (payload, err). -// On EOF (end-of-stream), returns ("", ""). On error, returns ("", err). -read(ch: int, max: int) -> (string @heap, string) { +// One read of up to `max` bytes: (data, "") with what arrived, which +// may be less than was written (the channel is a byte stream; framing +// is the caller's), ("", "") at end of stream, or ("", err). +// Binary-safe. Blocks until something arrives. +read(ch: int, max: int) -> { return ipc_read_raw(ch, max) } -// Send one open file descriptor `fd` across socket channel `ch`, accompanied by -// `payload` (must be at least 1 byte). Returns "" on success, error message on failure. +// Pass the open descriptor `fd` to the process at the other end of +// socket `ch`, attached to `payload`, which must be at least one byte +// (on a stream socket a descriptor travels with bytes). The receiver +// gets its own descriptor for the same open file; this side's `fd` +// stays open, so close it when this side is done with it. send_fd(ch: int, fd: int, payload: string) -> string { return ipc_send_fd_raw(ch, fd, payload) } -// Receive one open file descriptor and payload up to `max` bytes from socket -// channel `ch`. Returns (received_fd, payload, err). -recv_fd(ch: int, max: int) -> (int, string @heap, string) { +// Receive one descriptor from socket `ch`, with the bytes that came +// with it (up to `max`): (fd, payload, ""), or (-1, "", err). The +// descriptor is close-on-exec. A message carrying no descriptor, more +// than one, or control data of another kind is an error, and every +// descriptor that did arrive is closed first. +recv_fd(ch: int, max: int) -> { return ipc_recv_fd_raw(ch, max) } diff --git a/std/ipc/test_ipc.ae b/std/ipc/test_ipc.ae index fcb4aa54b..af8a2c914 100644 --- a/std/ipc/test_ipc.ae +++ b/std/ipc/test_ipc.ae @@ -1,181 +1,188 @@ -// std/ipc/test_ipc.ae — unit spec for std.ipc socket channel & descriptor passing - +// std.ipc, co-located with the module: what works within one process, +// over a socket pair from os.socketpair(). The cross-process half (a +// child spawned with os.run_socket, a hostile peer sending descriptors +// recv_fd must refuse, a 1 MB transfer each way) is +// tests/integration/std_ipc_socket/. +// +// On Windows the socket channel does not exist, and the suite checks +// that each entry point says so instead of skipping: portable code calls +// them unguarded. + +import std.spec import std.ipc import std.os import std.fs import std.io import std.bytes import std.string -import std.dir - -extern exit(code: int) - -assert_true(cond: int, msg: string) { - if cond == 0 { - println("FAIL: ${msg}") - exit(1) - } else { - println(" PASS ${msg}") - } +import std.list + +// 0 when a child process inherits `fd`, as it would when the descriptor +// is not close-on-exec: the shell's redirection from it fails otherwise. +child_sees_fd(fd: int) -> int { + argv = list.new() + list.add(argv, "-c") + list.add(argv, "exec 2>/dev/null; exec 9>&${fd}") + _out, code, _err = os.run_capture("/bin/sh", argv, null) + list.free(argv) + return code } -assert_eq_str(got: string, expected: string, msg: string) { - if got != expected { - println("FAIL: ${msg} (got '${got}', expected '${expected}')") - exit(1) - } else { - println(" PASS ${msg}") +// The first `n` bytes of the file `fd` reads from. +read_fd(fd: int, n: int) -> string { + buf = bytes.new(n) + got, err = io.fd_read_into(fd, bytes.capacity_view(buf)[0..n]) + if err != "" { + bytes.free(buf) + return "read failed: ${err}" } + s = bytes.to_string(buf, got) + bytes.free(buf) + return s } -assert_eq_int(got: int, expected: int, msg: string) { - if got != expected { - println("FAIL: ${msg} (got ${got}, expected ${expected})") - exit(1) - } else { - println(" PASS ${msg}") +main() { + fw = spec.init() + on_windows = string.equals(os.platform(), "windows") + + spec.describe(fw, "std.ipc without a parent channel") { + spec.it("parent_channel is -1 and its kind is empty") callback { + spec.assert_eq(ipc.parent_channel(), -1, "no AETHER_IPC_FD, no channel") + spec.assert_str_eq(ipc.parent_channel_kind(), "", "no channel, no kind") + } } -} -count_open_fds() -> int { - entries, dir_err = dir.list("/proc/self/fd") - if dir_err != "" || entries == null { return -1 } - c = dir.list_count(entries) - dir.list_free(entries) - return c -} + if on_windows == 1 { + spec.describe(fw, "std.ipc socket channel on Windows") { + spec.it("reports each socket entry point as unsupported") callback { + _a, _b, sp_err = os.socketpair() + spec.assert_err(sp_err, "os.socketpair") + spec.assert_err(ipc.send_fd(3, 4, "x"), "ipc.send_fd") + fd, _p, rerr = ipc.recv_fd(3, 16) + spec.assert_eq(fd, -1, "ipc.recv_fd gives no descriptor") + spec.assert_err(rerr, "ipc.recv_fd") + } + } + return spec.run_summary(fw) + } -main() { - println("=== std.ipc test_ipc ===") - - // 1. Initial state checks - assert_eq_int(ipc.parent_channel(), -1, "parent_channel is -1 when no channel exists") - assert_eq_str(ipc.parent_channel_kind(), "", "parent_channel_kind is empty when no channel exists") - - // 2. os.socketpair checks - fd_a, fd_b, sp_err = os.socketpair() - assert_eq_str(sp_err, "", "socketpair returns valid open descriptors") - assert_true(fd_a >= 0 && fd_b >= 0, "socketpair fds are non-negative") - assert_true(ipc.get_cloexec_raw(fd_a) != 0, "socketpair fd_a is close-on-exec") - assert_true(ipc.get_cloexec_raw(fd_b) != 0, "socketpair fd_b is close-on-exec") - - // 3. Bidirectional write / read roundtrips (including embedded NUL byte) - msg_zero = string.concat("hello\0world", "!!") - w_err1 = ipc.write(fd_a, msg_zero) - assert_eq_str(w_err1, "", "ipc.write with embedded zero byte succeeds") - - read_data1, r_err1 = ipc.read(fd_b, 100) - assert_eq_str(r_err1, "", "ipc.read succeeds") - assert_eq_int(string.length(read_data1), string.length(msg_zero), "read payload preserves length with zero byte") - assert_eq_str(read_data1, msg_zero, "read payload matches msg_zero") - - // Reverse direction (fd_b -> fd_a) - rev_msg = "hello from b to a" - w_err_rev = ipc.write(fd_b, rev_msg) - assert_eq_str(w_err_rev, "", "ipc.write reverse b to a succeeds") - read_rev, r_err_rev = ipc.read(fd_a, 100) - assert_eq_str(r_err_rev, "", "ipc.read reverse on a succeeds") - assert_eq_str(read_rev, rev_msg, "reverse payload matches") - - // 4. Large payload roundtrip (1 MB) to test short write looping / EINTR handling - mb_chunk = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" // 64 bytes - // Build 1 MB = 16384 * 64 bytes - big_buf = string.concat("", "") - i = 0 - while i < 16384 { - big_buf = string.concat(big_buf, mb_chunk) - i = i + 1 + spec.describe(fw, "os.socketpair") { + // The probe's own control: a descriptor that is inherited (stdout) + // must read as inherited, or "not inherited" below proves nothing. + spec.it("child_sees_fd sees a descriptor a child does inherit") callback { + spec.assert_eq(child_sees_fd(1), 0, "stdout is inherited") + } + + spec.it("gives two open descriptors, both close-on-exec") callback { + a, b, err = os.socketpair() + spec.assert_ok(err, "socketpair") + spec.assert_true(a >= 0 && b >= 0, "both ends are descriptors") + spec.assert_not_eq(child_sees_fd(a), 0, "a child does not inherit end a") + spec.assert_not_eq(child_sees_fd(b), 0, "a child does not inherit end b") + io.fd_close(a) + io.fd_close(b) + } } - assert_eq_int(string.length(big_buf), 1048576, "constructed 1 MB payload") - - // Increase socket buffers to facilitate 1 MB transfers - _ = ipc.set_sock_buf_raw(fd_a, 262144) - _ = ipc.set_sock_buf_raw(fd_b, 262144) - - // Send asynchronously in a helper thread to avoid deadlock if socket buffer fills - _ = ipc.test_send_async_raw(fd_a, big_buf) - - // Read 1 MB in chunks - recvd_big = string.concat("", "") - chunk = "" - chunk_err = "" - while string.length(recvd_big) < 1048576 { - chunk, chunk_err = ipc.read(fd_b, 65536) - assert_eq_str(chunk_err, "", "chunk read error is empty") - if string.length(chunk) == 0 { break } - recvd_big = string.concat(recvd_big, chunk) + + spec.describe(fw, "ipc.write and ipc.read") { + spec.it("carry bytes both ways, a zero byte and all included") callback { + a, b, _ = os.socketpair() + there = string.concat("hello\0world", "\xff!") + spec.assert_ok(ipc.write(a, there), "write a to b") + got, err = ipc.read(b, 100) + spec.assert_ok(err, "read on b") + spec.assert_eq(string.length(got), 13, "every byte arrived, past the zero") + spec.assert_true(got == there, "and they are the bytes sent") + + spec.assert_ok(ipc.write(b, "and back"), "write b to a") + back, err2 = ipc.read(a, 100) + spec.assert_ok(err2, "read on a") + spec.assert_str_eq(back, "and back", "the reply arrived") + io.fd_close(a) + io.fd_close(b) + } + + spec.it("reads end of stream as no bytes and no error") callback { + a, b, _ = os.socketpair() + io.fd_close(a) + got, err = ipc.read(b, 100) + spec.assert_ok(err, "end of stream is not an error") + spec.assert_eq(string.length(got), 0, "and carries no bytes") + io.fd_close(b) + } + + // Without MSG_NOSIGNAL (SO_NOSIGPIPE on macOS) this write ends the + // whole test process with SIGPIPE, and the summary never prints. + spec.it("fails, rather than raising SIGPIPE, writing to a closed peer") callback { + a, b, _ = os.socketpair() + io.fd_close(b) + err = ipc.write(a, "into the void") + spec.assert_err_contains(err, "ipc.write", "an error naming the call") + io.fd_close(a) + } + + spec.it("refuses a bad descriptor and a non-positive max") callback { + spec.assert_err(ipc.write(-1, "x"), "write to -1") + _d, err = ipc.read(-1, 10) + spec.assert_err(err, "read from -1") + a, b, _ = os.socketpair() + _d2, err2 = ipc.read(a, 0) + spec.assert_err(err2, "read of max 0") + io.fd_close(a) + io.fd_close(b) + } } - assert_eq_int(string.length(recvd_big), 1048576, "read 1 MB payload length matches") - assert_eq_str(recvd_big, big_buf, "1 MB payload content matches") - - // 5. Descriptor passing (Rule 2: close-on-exec, Rule 5: refusal of empty payload) - tmp_path = "${os.temp_dir()}/std_ipc_test_file_${os.getpid()}.txt" - _ = fs.write_atomic(tmp_path, string.bytes("fd passing content\n")) - - t_fd, t_err = io.fd_open_read(tmp_path) - assert_eq_str(t_err, "", "opened temp file for reading") - - sfd_err = ipc.send_fd(fd_a, t_fd, "check_file") - assert_eq_str(sfd_err, "", "send_fd succeeds") - _ = io.fd_close(t_fd) - - recvd_fd, payload, rfd_err = ipc.recv_fd(fd_b, 100) - assert_eq_str(rfd_err, "", "recv_fd succeeds") - assert_true(recvd_fd >= 0, "received valid descriptor") - assert_eq_str(payload, "check_file", "received payload matches") - assert_true(ipc.get_cloexec_raw(recvd_fd) != 0, "received descriptor is close-on-exec") - - // Verify reading through received descriptor - buf_recvd = bytes.new(256) - n_recvd, read_f_err = io.fd_read_into(recvd_fd, bytes.capacity_view(buf_recvd)[0..256]) - assert_eq_str(read_f_err, "", "read from received descriptor succeeds") - _ = bytes.set_length(buf_recvd, n_recvd) - file_content = bytes.to_string(buf_recvd, n_recvd) - bytes.free(buf_recvd) - assert_eq_str(file_content, "fd passing content\n", "read content through received descriptor matches") - _ = io.fd_close(recvd_fd) - _ = fs.delete(tmp_path) - - // 6. Refusal of empty payload in send_fd - empty_sfd_err = ipc.send_fd(fd_a, fd_a, "") - assert_true(empty_sfd_err != "", "send_fd with empty payload is refused") - - // 7. Message without descriptor sent to recv_fd (Rule 5) - _ = ipc.write(fd_a, "plain payload no fd") - no_fd, _no_fd_payload, no_fd_err = ipc.recv_fd(fd_b, 100) - assert_eq_int(no_fd, -1, "recv_fd without descriptor returns fd=-1") - assert_true(no_fd_err != "", "recv_fd without descriptor returns non-empty error") - - // 8. SIGPIPE resistance (Rule 3): write to closed peer returns error - _ = io.fd_close(fd_b) - pipe_err = ipc.write(fd_a, "should fail quietly without SIGPIPE") - assert_true(pipe_err != "", "write to closed peer returns error") - _ = io.fd_close(fd_a) - - // 9. Linux leak test (Rule 1): sending multiple descriptors in one message - initial_fd_count = count_open_fds() - if initial_fd_count >= 0 { - s_a, s_b, _ = os.socketpair() - dummy1, _, _ = os.socketpair() - dummy2, _, _ = os.socketpair() - - before_fds = count_open_fds() - send_two_rc = ipc.test_ipc_send_two_fds(s_a, dummy1, dummy2, "two_fds_payload") - assert_eq_int(send_two_rc, 0, "sent two descriptors in one message via helper") - - bad_fd, _bad_payload, bad_err = ipc.recv_fd(s_b, 100) - assert_eq_int(bad_fd, -1, "recv_fd errors on multiple descriptors") - assert_true(bad_err != "", "recv_fd returns error on multiple descriptors") - - after_fds = count_open_fds() - assert_eq_int(after_fds, before_fds, "proc fd count is unchanged (no leaked descriptors)") - - _ = io.fd_close(s_a) - _ = io.fd_close(s_b) - _ = io.fd_close(dummy1) - _ = io.fd_close(dummy2) + + spec.describe(fw, "ipc.send_fd and ipc.recv_fd") { + spec.it("pass an open file the receiver reads through") callback { + path = "${os.temp_dir()}/std_ipc_spec_${os.getpid()}.txt" + fs.write_atomic(path, string.bytes("passed by descriptor\n")) + f, ferr = io.fd_open_read(path) + spec.assert_ok(ferr, "open the file") + + a, b, _ = os.socketpair() + spec.assert_ok(ipc.send_fd(a, f, "open this"), "send_fd") + io.fd_close(f) + fs.delete(path) + + got, payload, err = ipc.recv_fd(b, 100) + spec.assert_ok(err, "recv_fd") + spec.assert_true(got >= 0, "a descriptor arrived") + spec.assert_str_eq(payload, "open this", "with its payload") + spec.assert_str_eq(read_fd(got, 64), "passed by descriptor\n", + "the file reads through it, though the sender closed its own and the path is gone") + spec.assert_not_eq(child_sees_fd(got), 0, "the received descriptor is close-on-exec") + io.fd_close(got) + io.fd_close(a) + io.fd_close(b) + } + + spec.it("refuses an empty payload") callback { + a, b, _ = os.socketpair() + spec.assert_err_contains(ipc.send_fd(a, a, ""), "empty payload", "nothing to attach it to") + io.fd_close(a) + io.fd_close(b) + } + + spec.it("reports a message without a descriptor as an error") callback { + a, b, _ = os.socketpair() + ipc.write(a, "just bytes") + got, _p, err = ipc.recv_fd(b, 100) + spec.assert_eq(got, -1, "no descriptor") + spec.assert_err_contains(err, "no descriptor", "an error saying so") + io.fd_close(a) + io.fd_close(b) + } + + spec.it("reports end of stream as an error") callback { + a, b, _ = os.socketpair() + io.fd_close(a) + got, _p, err = ipc.recv_fd(b, 100) + spec.assert_eq(got, -1, "no descriptor") + spec.assert_err_contains(err, "end of stream", "an error saying so") + io.fd_close(b) + } } - println("All PASS") - return + return spec.run_summary(fw) } diff --git a/std/os/aether_os.c b/std/os/aether_os.c index 58b656a6d..e81460728 100644 --- a/std/os/aether_os.c +++ b/std/os/aether_os.c @@ -295,6 +295,23 @@ static const char* aether_exec_failure(int e) { } } +/* ": " in a per-thread buffer, valid until this + * thread's next call. strerror_r, not strerror: the runtime runs actor, + * worker and HTTP threads, and strerror's buffer is shared. */ +static const char* aether_os_strerror_msg(const char* what, int err) { + static __thread char msg[256]; + char buf[128]; + const char* reason = buf; + buf[0] = '\0'; +#if defined(__GLIBC__) && defined(_GNU_SOURCE) + reason = strerror_r(err, buf, sizeof buf); +#else + if (strerror_r(err, buf, sizeof buf) != 0) snprintf(buf, sizeof buf, "error %d", err); +#endif + snprintf(msg, sizeof msg, "%s: %s", what, reason); + return msg; +} + /* Reap a child whose exec failed. */ static void aether_reap_failed(pid_t pid) { int st = 0; @@ -1870,46 +1887,27 @@ typedef struct { int _0; int _1; int _2; const char* _3; } _tuple_int_int_int_st * takes the environment's lock, and a lock another thread held at the fork * is never released in the child. The array is malloc'd; its strings are * the base's and a literal. NULL when malloc fails. */ -#include - static char** aether_ipc_env(char** base) { static const char* const ipc[] = { "AETHER_IPC_FD=3" }; return aether_spawn_env(base ? base : aether_environ, ipc, 1); } +/* os.run_socket's child env: as aether_ipc_env, plus AETHER_IPC_KIND=socket, + * which ipc.parent_channel_kind() reads. Either of the two in `base` is + * replaced, not duplicated. */ static char** aether_ipc_socket_env(char** base) { static const char* const ipc[] = { "AETHER_IPC_FD=3", "AETHER_IPC_KIND=socket" }; return aether_spawn_env(base ? base : aether_environ, ipc, 2); } -static int aether_socketpair_cloexec(int fds[2]) { -#if defined(SOCK_CLOEXEC) - if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) == 0) { -#ifdef SO_NOSIGPIPE - int val = 1; - setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); - setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); -#endif - return 0; - } - if (errno != EINVAL) return -1; -#endif - if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) < 0) return -1; - (void)fcntl(fds[0], F_SETFD, FD_CLOEXEC); - (void)fcntl(fds[1], F_SETFD, FD_CLOEXEC); -#ifdef SO_NOSIGPIPE - int val = 1; - setsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); - setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &val, sizeof(val)); -#endif - return 0; -} - +/* A connected AF_UNIX stream pair, both ends close-on-exec, for a process + * to use itself (std.ipc's tests do) or to hand one end to a child it + * spawns some other way. Returns (fd_a, fd_b, err). */ _tuple_int_int_string os_socketpair_raw(void) { _tuple_int_int_string out = { -1, -1, "" }; int fds[2]; if (aether_socketpair_cloexec(fds) < 0) { - out._2 = strerror(errno); + out._2 = aether_os_strerror_msg("os.socketpair", errno); return out; } out._0 = fds[0]; @@ -1917,6 +1915,26 @@ _tuple_int_int_string os_socketpair_raw(void) { return out; } +/* Spawn child with a back-channel at fd 3, set AETHER_IPC_FD=3 in child's + * env. The channel is the write end of a pipe (os_run_pipe_raw) or one end + * of a socket pair (os_run_socket_raw, which also sets + * AETHER_IPC_KIND=socket). Returns (parent_fd, child_pid, err). On success, + * err is "" and caller must: + * 1. Read from (and, for a socket, write to) parent_fd. + * 2. Call os_wait_pid_raw(child_pid) to reap. + * 3. Close parent_fd. + * + * Failure modes return (-1, -1, "") with no child left to reap: + * a program that could not start reads so too, the reason from its exec + * (#3170). An explicit env is refused before the fork: the child's + * AETHER_IPC_FD would have to be spliced into it, and it used to make the + * child exit 127 without running the program. + * + * Pipe-buffer deadlock window: if child writes > OS pipe buffer + * (typically 16K-64K) without parent reading concurrently, the + * child blocks forever on its write. For payload-at-exit usage + * with large payloads, prefer os_run_pipe_drain_and_wait_raw. A socket + * has the same window in both directions. */ static _tuple_int_int_string aether_spawn_channel_raw(const char* prog, void* argv_list, void* env_list, int is_socket) { _tuple_int_int_string out = { -1, -1, "" }; if (!prog) { @@ -1931,7 +1949,7 @@ static _tuple_int_int_string aether_spawn_channel_raw(const char* prog, void* ar int chanfd[2]; if (is_socket) { if (aether_socketpair_cloexec(chanfd) != 0) { - out._2 = "socketpair failed"; + out._2 = aether_os_strerror_msg("os.run_socket: socketpair", errno); return out; } } else { @@ -1960,6 +1978,9 @@ static _tuple_int_int_string aether_spawn_channel_raw(const char* prog, void* ar return out; } + /* The child's env: the one given, or environ, with AETHER_IPC_FD=3 (and + * for a socket AETHER_IPC_KIND=socket). The spliced array borrows the + * given one's strings, so only its array goes. */ char** spliced = is_socket ? aether_ipc_socket_env(x.envp) : aether_ipc_env(x.envp); free(x.envp); x.envp = spliced; @@ -1985,6 +2006,7 @@ static _tuple_int_int_string aether_spawn_channel_raw(const char* prog, void* ar return out; } if (pid == 0) { + /* Child — wire chanfd[1] to fd 3; its env says so. */ aether_child_reset_signals(); aether_child_keep_stdio(); close(chanfd[0]); diff --git a/tests/integration/emit_lib_banned/test_emit_lib_banned.sh b/tests/integration/emit_lib_banned/test_emit_lib_banned.sh index c4e858109..75c8f08e0 100755 --- a/tests/integration/emit_lib_banned/test_emit_lib_banned.sh +++ b/tests/integration/emit_lib_banned/test_emit_lib_banned.sh @@ -28,7 +28,7 @@ check_banned() { for mod in \ std.net std.http std.http.client std.tcp std.udp \ std.fs std.file std.dir \ - std.os std.dl \ + std.os std.dl std.ipc \ contrib.host.python; do check_banned "$mod" done diff --git a/tests/integration/std_ipc_socket/child.ae b/tests/integration/std_ipc_socket/child.ae index 76c1f887c..ba96e8168 100644 --- a/tests/integration/std_ipc_socket/child.ae +++ b/tests/integration/std_ipc_socket/child.ae @@ -1,81 +1,59 @@ -// Integration test child process for std.ipc socket channel and fd passing. +// The child for probe.ae: spawned with os.run_socket, it finds its +// channel with std.ipc, answers a request, reads a file through a +// descriptor the parent passes, and echoes 1 MB back. import std.ipc import std.io import std.bytes +import std.strbuilder import std.string extern exit(code: int) +const BIG = 1048576 + +fail(msg: string) { + println("FAIL child: ${msg}") + exit(1) +} + main() { ch = ipc.parent_channel() + if ch < 0 { fail("no parent channel") } kind = ipc.parent_channel_kind() + if kind != "socket" { fail("expected a socket channel, got '${kind}'") } - if ch < 0 { - println("FAIL child: no parent channel") - exit(1) - } - - if kind != "socket" { - println("FAIL child: expected socket channel, got '${kind}'") - exit(1) - } - - // 1. Read request from parent req, rerr = ipc.read(ch, 100) - if rerr != "" { - println("FAIL child read request: ${rerr}") - exit(1) - } - if req != "hello child" { - println("FAIL child read request mismatch: got '${req}'") - exit(1) - } - - // 2. Reply to parent + if rerr != "" { fail("read request: ${rerr}") } + if req != "hello child" { fail("request was '${req}'") } werr = ipc.write(ch, "hello parent") - if werr != "" { - println("FAIL child write reply: ${werr}") - exit(1) - } - - // 3. Receive file descriptor from parent - recvd_fd, payload, recv_err = ipc.recv_fd(ch, 1024) - if recv_err != "" { - println("FAIL child recv_fd: ${recv_err}") - exit(1) - } - if recvd_fd < 0 { - println("FAIL child recv_fd invalid descriptor: ${recvd_fd}") - exit(1) - } - if payload != "read_file_req" { - println("FAIL child recv_fd payload mismatch: got '${payload}'") - exit(1) - } + if werr != "" { fail("write reply: ${werr}") } - // 4. Read file through received descriptor + fd, payload, ferr = ipc.recv_fd(ch, 1024) + if ferr != "" { fail("recv_fd: ${ferr}") } + if payload != "read_file_req" { fail("recv_fd payload was '${payload}'") } buf = bytes.new(256) - n_read, read_err = io.fd_read_into(recvd_fd, bytes.capacity_view(buf)[0..256]) - if read_err != "" { - println("FAIL child fd_read_into: ${read_err}") - bytes.free(buf) - exit(1) - } - _ = bytes.set_length(buf, n_read) - content = bytes.to_string(buf, n_read) + n, read_err = io.fd_read_into(fd, bytes.capacity_view(buf)[0..256]) + if read_err != "" { fail("read through the descriptor: ${read_err}") } + content = bytes.to_string(buf, n) bytes.free(buf) - - // Close received descriptor - _ = io.fd_close(recvd_fd) - - // 5. Send file contents back to parent + io.fd_close(fd) werr2 = ipc.write(ch, "content: ${content}") - if werr2 != "" { - println("FAIL child write content reply: ${werr2}") - exit(1) - } - - _ = io.fd_close(ch) + if werr2 != "" { fail("write content: ${werr2}") } + + // Take all of BIG before answering: the parent's write of it can + // only finish while this side reads. + sb = strbuilder.new(BIG) + while strbuilder.length(sb) < BIG { + chunk, cerr = ipc.read(ch, 65536) + if cerr != "" { fail("read big: ${cerr}") } + if string.length(chunk) == 0 { fail("end of stream inside big") } + strbuilder.append(sb, chunk) + } + big = strbuilder.finish(sb) + berr = ipc.write(ch, big) + if berr != "" { fail("write big: ${berr}") } + + io.fd_close(ch) exit(0) } diff --git a/tests/integration/std_ipc_socket/hostile_peer.c b/tests/integration/std_ipc_socket/hostile_peer.c new file mode 100644 index 000000000..55bacc48e --- /dev/null +++ b/tests/integration/std_ipc_socket/hostile_peer.c @@ -0,0 +1,70 @@ +/* A peer that breaks std.ipc's descriptor rules, for probe.ae. Spawned with + * os.run_socket, so its channel is fd AETHER_IPC_FD. Each message waits for + * the probe's "next" first, so no two arrive in one recvmsg: + * 1. two descriptors in one message + * 2. ten descriptors in one message (more than recv_fd makes room for: + * the kernel truncates the control data) + * 3. one descriptor, as the rules allow, to show the channel still works + * Each descriptor is a fresh open of /dev/null, so every one the probe is + * handed is a new entry in its descriptor table. Exits 0 when the probe + * closes the channel. Built by test_std_ipc_socket.sh with $CC: the public + * API cannot send more than one descriptor, which is the point. */ +#include +#include +#include +#include +#include +#include + +static int ch; + +static void await_next(void) { + char buf[4]; + size_t got = 0; + while (got < sizeof buf) { + ssize_t n = read(ch, buf + got, sizeof buf - got); + if (n <= 0) exit(0); + got += (size_t)n; + } +} + +static void send_fds(int count, const char* payload) { + int fds[16]; + for (int i = 0; i < count; i++) { + fds[i] = open("/dev/null", O_RDONLY); + if (fds[i] < 0) { perror("hostile_peer: open"); exit(2); } + } + union { + char buf[CMSG_SPACE(sizeof(int) * 16)]; + struct cmsghdr align; + } control; + memset(&control, 0, sizeof control); + struct iovec iov = { (void*)payload, strlen(payload) }; + struct msghdr msg; + memset(&msg, 0, sizeof msg); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control.buf; + msg.msg_controllen = CMSG_SPACE(sizeof(int) * (size_t)count); + struct cmsghdr* c = CMSG_FIRSTHDR(&msg); + c->cmsg_level = SOL_SOCKET; + c->cmsg_type = SCM_RIGHTS; + c->cmsg_len = CMSG_LEN(sizeof(int) * (size_t)count); + memcpy(CMSG_DATA(c), fds, sizeof(int) * (size_t)count); + if (sendmsg(ch, &msg, 0) < 0) { perror("hostile_peer: sendmsg"); exit(2); } + for (int i = 0; i < count; i++) close(fds[i]); +} + +int main(void) { + const char* s = getenv("AETHER_IPC_FD"); + if (!s) { fprintf(stderr, "hostile_peer: no AETHER_IPC_FD\n"); return 2; } + ch = atoi(s); + await_next(); + send_fds(2, "two"); + await_next(); + send_fds(10, "ten"); + await_next(); + send_fds(1, "one"); + await_next(); + return 0; +} diff --git a/tests/integration/std_ipc_socket/probe.ae b/tests/integration/std_ipc_socket/probe.ae index 9d0160839..688576489 100644 --- a/tests/integration/std_ipc_socket/probe.ae +++ b/tests/integration/std_ipc_socket/probe.ae @@ -1,15 +1,22 @@ -// std.ipc socket channel and descriptor passing integration test probe. +// std.ipc socket channel, across processes. Spawns child.ae with +// os.run_socket and runs a conversation over the channel: a request and +// its reply, a file passed by descriptor and read by the child, 1 MB +// each way. Then spawns hostile_peer (its path in AETHER_IPC_HOSTILE_PEER, +// built by the shell script) and checks recv_fd refuses what it sends +// without the refused descriptors staying open in this process. import std.ipc import std.os import std.fs import std.io +import std.dir import std.string +import std.strbuilder import std.list - -extern exit(code: int) +import std.spec const CHILD_SRC = "tests/integration/std_ipc_socket/child.ae" +const BIG = 1048576 build_child(bin: string) -> string { argv = list.new() @@ -17,116 +24,126 @@ build_child(bin: string) -> string { list.add(argv, CHILD_SRC) list.add(argv, "-o") list.add(argv, bin) - out, exit_code, err = os.run_capture("build/ae", argv, null) + out, code, err = os.run_capture("build/ae", argv, null) list.free(argv) if err != "" { return "spawn error: ${err}" } - if exit_code != 0 { - return "ae build exit ${exit_code}: ${out}" - } + if code != 0 { return "ae build exit ${code}: ${out}" } return "" } -main() { - println("std_ipc_socket test probe") - - child_bin = "${os.temp_dir()}/std_ipc_socket_child_${os.getpid()}" - berr = build_child(child_bin) - if berr != "" { - println("FAIL: cannot build child: ${berr}") - exit(1) - } - - // Create temp file for descriptor passing - tmp_path = "${os.temp_dir()}/std_ipc_socket_file_${os.getpid()}.txt" - _ = fs.write_atomic(tmp_path, string.bytes("secret data 42\n")) - - // Spawn child via os.run_socket - argv = list.new() - parent_fd, child_pid, spawn_err = os.run_socket(child_bin, argv, null) - list.free(argv) - - if spawn_err != "" { - println("FAIL os.run_socket: ${spawn_err}") - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } - - if parent_fd < 0 || child_pid <= 0 { - println("FAIL os.run_socket invalid return: fd=${parent_fd}, pid=${child_pid}") - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } - - // 1. Send request to child - werr = ipc.write(parent_fd, "hello child") - if werr != "" { - println("FAIL write request: ${werr}") - _ = io.fd_close(parent_fd) - _ = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } - - // 2. Read reply from child - rep_msg, rerr = ipc.read(parent_fd, 100) - if rerr != "" || rep_msg != "hello parent" { - println("FAIL read reply: err='${rerr}', rep_msg='${rep_msg}'") - _ = io.fd_close(parent_fd) - _ = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } - - // 3. Open temp file and send descriptor - f_fd, f_err = io.fd_open_read(tmp_path) - if f_err != "" || f_fd < 0 { - println("FAIL open temp file: err='${f_err}', fd=${f_fd}") - _ = io.fd_close(parent_fd) - _ = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } - - send_err = ipc.send_fd(parent_fd, f_fd, "read_file_req") - _ = io.fd_close(f_fd) // parent can close its handle after sending - - if send_err != "" { - println("FAIL send_fd: ${send_err}") - _ = io.fd_close(parent_fd) - _ = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) - } +// Entries in this process's descriptor table, or -1 where it cannot be +// listed. /dev/fd is the table on macOS; FreeBSD without fdescfs shows +// only 0-2 there, which makes the leak checks below vacuous but not wrong. +open_fds() -> int { + path = "/proc/self/fd" + if fs.exists(path) == 0 { path = "/dev/fd" } + entries, err = dir.list(path) + if err != "" { return -1 } + n = dir.list_count(entries) + dir.list_free(entries) + return n +} - // 4. Read report back from child - report, report_err = ipc.read(parent_fd, 1024) - if report_err != "" || report != "content: secret data 42\n" { - println("FAIL read report: err='${report_err}', report='${report}'") - _ = io.fd_close(parent_fd) - _ = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - exit(1) +// 1 MB of a repeating pattern, so a lost or reordered chunk shows. +big_payload() -> string { + sb = strbuilder.new(BIG) + i = 0 + while strbuilder.length(sb) < BIG { + strbuilder.append(sb, "${i % 10}0123456789abcdefghijklmnopqrstuvwxyz") + i = i + 1 } + strbuilder.truncate(sb, BIG) + s = strbuilder.finish(sb) + return s +} - _ = io.fd_close(parent_fd) - - // Reap child - exit_code, wpid_err = os.wait_pid(child_pid) - _ = fs.delete(tmp_path) - _ = fs.delete(child_bin) - - if wpid_err != "" || exit_code != 0 { - println("FAIL wait_pid: err='${wpid_err}', exit_code=${exit_code}") - exit(1) +main() { + fw = spec.init() + tmp = os.temp_dir() + pid = os.getpid() + child_bin = "${tmp}/std_ipc_socket_child_${pid}" + file_path = "${tmp}/std_ipc_socket_file_${pid}.txt" + + spec.describe(fw, "std_ipc_socket") { + spec.it("talks to a child over os.run_socket") callback { + spec.assert_ok(build_child(child_bin), "build the child") + fs.write_atomic(file_path, string.bytes("secret data 42\n")) + + argv = list.new() + ch, child, err = os.run_socket(child_bin, argv, null) + list.free(argv) + spec.assert_ok(err, "os.run_socket") + + spec.assert_ok(ipc.write(ch, "hello child"), "write the request") + answer, rerr = ipc.read(ch, 100) + spec.assert_ok(rerr, "read the reply") + spec.assert_str_eq(answer, "hello parent", "the child answered") + + f, ferr = io.fd_open_read(file_path) + spec.assert_ok(ferr, "open the file") + spec.assert_ok(ipc.send_fd(ch, f, "read_file_req"), "send its descriptor") + io.fd_close(f) + report, perr = ipc.read(ch, 1024) + spec.assert_ok(perr, "read the child's report") + spec.assert_str_eq(report, "content: secret data 42\n", + "the child read the file through the passed descriptor") + + big = big_payload() + spec.assert_ok(ipc.write(ch, big), "write 1 MB") + sb = strbuilder.new(BIG) + while strbuilder.length(sb) < BIG { + chunk, cerr = ipc.read(ch, 65536) + spec.assert_ok(cerr, "read the echo") + if string.length(chunk) == 0 { break } + strbuilder.append(sb, chunk) + } + echo = strbuilder.finish(sb) + spec.assert_eq(string.length(echo), BIG, "1 MB came back") + spec.assert_true(string.equals(echo, big) == 1, "byte for byte") + + io.fd_close(ch) + code, werr = os.wait_pid(child) + spec.assert_ok(werr, "reap the child") + spec.assert_eq(code, 0, "the child exited cleanly") + fs.delete(file_path) + fs.delete(child_bin) + } + + spec.it("refuses a hostile peer's descriptors and closes them") callback { + peer = os.getenv("AETHER_IPC_HOSTILE_PEER") + spec.assert_true(string.length(peer) > 0, "AETHER_IPC_HOSTILE_PEER names the peer") + argv = list.new() + ch, child, err = os.run_socket(peer, argv, null) + list.free(argv) + spec.assert_ok(err, "spawn the hostile peer") + base = open_fds() + + ipc.write(ch, "next") + fd2, _p2, err2 = ipc.recv_fd(ch, 64) + spec.assert_eq(fd2, -1, "two descriptors: none handed over") + spec.assert_err_contains(err2, "more than one descriptor", "two descriptors: refused") + spec.assert_eq(open_fds(), base, "two descriptors: both closed") + + ipc.write(ch, "next") + fd10, _p10, err10 = ipc.recv_fd(ch, 64) + spec.assert_eq(fd10, -1, "ten descriptors: none handed over") + spec.assert_err_contains(err10, "truncated", "ten descriptors: refused") + spec.assert_eq(open_fds(), base, "ten descriptors: all closed") + + ipc.write(ch, "next") + fd1, p1, err1 = ipc.recv_fd(ch, 64) + spec.assert_ok(err1, "one descriptor: accepted, the channel still works") + spec.assert_str_eq(p1, "one", "one descriptor: its payload") + spec.assert_eq(open_fds(), base + 1, "one descriptor: exactly one more open") + io.fd_close(fd1) + + ipc.write(ch, "next") + io.fd_close(ch) + code, werr = os.wait_pid(child) + spec.assert_ok(werr, "reap the peer") + spec.assert_eq(code, 0, "the peer ran to the end") + } } - println("PASS std_ipc_socket round-trip and fd-passing successful") - exit(0) + return spec.run_summary(fw) } diff --git a/tests/integration/std_ipc_socket/test_std_ipc_socket.sh b/tests/integration/std_ipc_socket/test_std_ipc_socket.sh index f66689401..f7be7a580 100644 --- a/tests/integration/std_ipc_socket/test_std_ipc_socket.sh +++ b/tests/integration/std_ipc_socket/test_std_ipc_socket.sh @@ -1,13 +1,22 @@ #!/bin/sh +# std.ipc socket channel integration test: probe.ae spawns child.ae with +# os.run_socket and holds a conversation with it, then spawns +# hostile_peer.c (built here: no public API sends more than one +# descriptor) and checks recv_fd refuses it without leaking descriptors. +# +# Skips on Windows, where the socket channel is not implemented. + set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" AE="$ROOT/build/ae" +# The platform from uname, never from the environment's OS, which an MSYS2 +# login shell started from another shell does not carry (check_platform_env.py). case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) - echo " [SKIP] std_ipc_socket: std.ipc socket channel is POSIX-only in v1" + echo " [SKIP] std_ipc_socket: the std.ipc socket channel is POSIX-only" exit 0 ;; esac @@ -18,14 +27,31 @@ fi cd "$ROOT" || exit 1 +# The probe builds its child in the temp directory under a name of its own +# process (#2771); TMPDIR names this run's directory, which the trap removes. TMPDIR="$(mktemp -d)" export TMPDIR trap 'rm -rf "$TMPDIR" || true' EXIT +AETHER_IPC_HOSTILE_PEER="$TMPDIR/hostile_peer" +export AETHER_IPC_HOSTILE_PEER +if ! ${CC:-cc} -o "$AETHER_IPC_HOSTILE_PEER" "$SCRIPT_DIR/hostile_peer.c" >"$TMPDIR/cc.log" 2>&1; then + echo " [FAIL] std_ipc_socket: cannot build hostile_peer.c" + sed 's/^/ /' "$TMPDIR/cc.log" + exit 1 +fi + if ! AETHER_HOME="$ROOT" "$AE" run "$SCRIPT_DIR/probe.ae" >"$TMPDIR/out.log" 2>&1; then echo " [FAIL] std_ipc_socket" tail -40 "$TMPDIR/out.log" | sed 's/^/ /' exit 1 fi +if ! grep -q "2 passing" "$TMPDIR/out.log"; then + echo " [FAIL] std_ipc_socket: not both cases passed" + tail -40 "$TMPDIR/out.log" | sed 's/^/ /' + exit 1 +fi + echo " [PASS] std_ipc_socket" +exit 0 diff --git a/tests/runtime/test_ipc_write.c b/tests/runtime/test_ipc_write.c new file mode 100644 index 000000000..aebfef7e0 --- /dev/null +++ b/tests/runtime/test_ipc_write.c @@ -0,0 +1,107 @@ +// std.ipc's write loop (std/ipc/aether_ipc.c): ipc.write must deliver every +// byte through a send that a signal cuts short, and retry one a signal +// interrupts before it sent anything (EINTR). A blocking send on a socket +// never comes back short by itself, so a test that only writes a large +// payload passes with or without the loop; this one makes the kernel return +// early. The sending thread is signalled over and over while its send is +// blocked on a full socket, its reader stalled. +#include "test_harness.h" +#ifndef _WIN32 +#include "../../runtime/utils/aether_spawn.h" +#include +#include +#include +#include +#include +#include +#include + +const char* ipc_write_raw(int fd, const void* bytes); + +#define IPC_WRITE_LEN (1 << 20) /* far past a socket's buffer: the send blocks */ +#define IPC_STALL_AT (256 * 1024) + +static pthread_t ipc_write_sender; +static int ipc_write_stop; + +static void ipc_write_on_signal(int sig) { (void)sig; } + +/* Signals the sending thread, not the process, so the one blocked in send + * is the one interrupted. */ +static void* ipc_write_pester(void* arg) { + (void)arg; + while (!__atomic_load_n(&ipc_write_stop, __ATOMIC_ACQUIRE)) { + pthread_kill(ipc_write_sender, SIGUSR1); + usleep(5000); + } + return NULL; +} + +static char ipc_write_byte(size_t i) { return (char)('a' + (i * 7 + i / 4093) % 26); } + +/* The reader: stalls before reading and again a quarter of the way in, + * then checks it got every byte, in order. Exits 0 when it did. */ +static void ipc_write_reader(int fd) { + char buf[4096]; + size_t got = 0; + int stalled = 0; + usleep(200 * 1000); + for (;;) { + ssize_t n = read(fd, buf, sizeof buf); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + for (ssize_t i = 0; i < n; i++) { + if (buf[i] != ipc_write_byte(got + (size_t)i)) _exit(2); + } + got += (size_t)n; + if (!stalled && got >= IPC_STALL_AT) { + stalled = 1; + usleep(200 * 1000); + } + } + _exit(got == IPC_WRITE_LEN ? 0 : 1); +} + +TEST_CATEGORY(ipc_write_finishes_sends_signals_cut_short, TEST_CATEGORY_RUNTIME) { + int sv[2]; + ASSERT_EQ(0, aether_socketpair_cloexec(sv)); + char* data = (char*)malloc(IPC_WRITE_LEN + 1); + ASSERT_NOT_NULL(data); + for (size_t i = 0; i < IPC_WRITE_LEN; i++) data[i] = ipc_write_byte(i); + data[IPC_WRITE_LEN] = '\0'; + + pid_t pid = fork(); + ASSERT_TRUE(pid >= 0); + if (pid == 0) { + close(sv[0]); + ipc_write_reader(sv[1]); + } + close(sv[1]); + + /* No SA_RESTART: an interrupted send returns what it sent, or EINTR. */ + struct sigaction sa, old; + memset(&sa, 0, sizeof sa); + sa.sa_handler = ipc_write_on_signal; + sigemptyset(&sa.sa_mask); + sigaction(SIGUSR1, &sa, &old); + ipc_write_sender = pthread_self(); + __atomic_store_n(&ipc_write_stop, 0, __ATOMIC_RELEASE); + pthread_t pester; + int started = pthread_create(&pester, NULL, ipc_write_pester, NULL) == 0; + + const char* err = ipc_write_raw(sv[0], data); + + __atomic_store_n(&ipc_write_stop, 1, __ATOMIC_RELEASE); + if (started) pthread_join(pester, NULL); + sigaction(SIGUSR1, &old, NULL); + close(sv[0]); + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + free(data); + + ASSERT_TRUE(started); + ASSERT_STREQ("", err); + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_EQ(0, WEXITSTATUS(status)); +} +#endif From a3d51be855896f2123fa98250e42718122fa2e53 Mon Sep 17 00:00:00 2001 From: paul Date: Sat, 31 Oct 2026 05:22:43 +0000 Subject: [PATCH 3/3] std.ipc: recv_fd sees every descriptor; spawn builds without sockets 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 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 --- runtime/utils/aether_spawn.c | 9 +++++++ std/ipc/README.md | 5 +++- std/ipc/aether_ipc.c | 26 ++++++++++++++----- .../integration/std_ipc_socket/hostile_peer.c | 5 ++-- tests/integration/std_ipc_socket/probe.ae | 2 +- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/runtime/utils/aether_spawn.c b/runtime/utils/aether_spawn.c index bab722977..8bbaa2ca1 100644 --- a/runtime/utils/aether_spawn.c +++ b/runtime/utils/aether_spawn.c @@ -10,7 +10,9 @@ #include #include #include +#if AETHER_HAS_NETWORKING #include +#endif #include #include #ifdef __APPLE__ @@ -138,6 +140,12 @@ pid_t aether_fork(void) { * with pipe2 has SOCK_CLOEXEC too; macOS has neither and takes the lock as * aether_pipe_cloexec does. */ int aether_socketpair_cloexec(int fds[2]) { +#if !AETHER_HAS_NETWORKING + /* No sockets on this target (AETHER_NO_NETWORKING: embedded builds). */ + (void)fds; + errno = ENOSYS; + return -1; +#else #ifdef AETHER_HAVE_PIPE2 if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) return -1; #else @@ -156,6 +164,7 @@ int aether_socketpair_cloexec(int fds[2]) { setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); #endif return 0; +#endif } #endif diff --git a/std/ipc/README.md b/std/ipc/README.md index 090a3964b..291e2dea4 100644 --- a/std/ipc/README.md +++ b/std/ipc/README.md @@ -104,7 +104,10 @@ main() { was truncated, that carries more than one descriptor, or a control message other than `SCM_RIGHTS` is an error, and every descriptor that did arrive is closed first, so a hostile peer cannot fill the - receiver's descriptor table with messages it refuses. + receiver's descriptor table with messages it refuses. `recv_fd` makes + room for more descriptors than one message can carry (Linux allows 253, + macOS 254), so none arrives unseen: macOS installs every descriptor of a + truncated message, including those past the buffer. 2. **Close-on-exec at receipt.** On Linux and FreeBSD the descriptor is created close-on-exec (`MSG_CMSG_CLOEXEC`). macOS lacks that and sets it straight after receipt, so a `fork` on another thread in between can diff --git a/std/ipc/aether_ipc.c b/std/ipc/aether_ipc.c index 6a16d354f..886476dc7 100644 --- a/std/ipc/aether_ipc.c +++ b/std/ipc/aether_ipc.c @@ -108,11 +108,14 @@ _tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { #define IPC_NOSIGNAL 0 #endif -/* The most descriptors one recvmsg makes room for. A message carrying more - * is truncated by the kernel (MSG_CTRUNC), which closes what did not fit; - * recv_fd closes the rest. Room for more than one is what lets recv_fd see - * a second descriptor and refuse it, rather than only a truncation. */ -#define IPC_MAX_FDS 8 +/* The most descriptors one recvmsg makes room for: more than any one + * message can carry (Linux's SCM_MAX_FD is 253, macOS refuses past 254), so + * every descriptor a peer sends is seen and a refusal closes them all. Room + * for fewer is not safe: macOS truncates the control data (MSG_CTRUNC) but + * still installs every descriptor, and those past the buffer stay open in + * this process with no way to name them. A truncation (another kernel's + * limit) is still refused. */ +#define IPC_MAX_FDS 256 /* ": " in a per-thread buffer, valid until this * thread's next failing call. */ @@ -306,12 +309,23 @@ _tuple_int_string_string ipc_recv_fd_raw(int ch, int max) { int fds[IPC_MAX_FDS]; size_t nfds = 0; int foreign = 0; + /* Only the bytes the kernel filled count. Under MSG_CTRUNC macOS leaves + * cmsg_len at what was sent (ten descriptors' worth in a buffer with + * room for eight); the descriptors it claims past the buffer are stack, + * and closing them would close whatever descriptors they happened to + * name. */ + const char* control_end = (const char*)msg.msg_control + msg.msg_controllen; for (struct cmsghdr* c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) { + if ((const char*)c + CMSG_LEN(0) > control_end) break; if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) { foreign = 1; continue; } - size_t count = (c->cmsg_len - CMSG_LEN(0)) / sizeof(int); + size_t len = c->cmsg_len; + size_t room = (size_t)(control_end - (const char*)c); + if (len > room) len = room; + if (len < CMSG_LEN(0)) break; + size_t count = (len - CMSG_LEN(0)) / sizeof(int); for (size_t i = 0; i < count; i++) { int fd; memcpy(&fd, CMSG_DATA(c) + i * sizeof(int), sizeof fd); diff --git a/tests/integration/std_ipc_socket/hostile_peer.c b/tests/integration/std_ipc_socket/hostile_peer.c index 55bacc48e..2203887d0 100644 --- a/tests/integration/std_ipc_socket/hostile_peer.c +++ b/tests/integration/std_ipc_socket/hostile_peer.c @@ -2,8 +2,9 @@ * os.run_socket, so its channel is fd AETHER_IPC_FD. Each message waits for * the probe's "next" first, so no two arrive in one recvmsg: * 1. two descriptors in one message - * 2. ten descriptors in one message (more than recv_fd makes room for: - * the kernel truncates the control data) + * 2. ten descriptors in one message (more than the eight recv_fd once + * made room for: macOS then installed all ten but showed eight, and + * the two it hid stayed open in the probe) * 3. one descriptor, as the rules allow, to show the channel still works * Each descriptor is a fresh open of /dev/null, so every one the probe is * handed is a new entry in its descriptor table. Exits 0 when the probe diff --git a/tests/integration/std_ipc_socket/probe.ae b/tests/integration/std_ipc_socket/probe.ae index 688576489..7246748f8 100644 --- a/tests/integration/std_ipc_socket/probe.ae +++ b/tests/integration/std_ipc_socket/probe.ae @@ -127,7 +127,7 @@ main() { ipc.write(ch, "next") fd10, _p10, err10 = ipc.recv_fd(ch, 64) spec.assert_eq(fd10, -1, "ten descriptors: none handed over") - spec.assert_err_contains(err10, "truncated", "ten descriptors: refused") + spec.assert_err_contains(err10, "more than one descriptor", "ten descriptors: refused") spec.assert_eq(open_fds(), base, "ten descriptors: all closed") ipc.write(ch, "next")