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/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..b582c9db5 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 — 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

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

observe

os

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

- 80 exports + 84 exports

path

@@ -1517,9 +1517,18 @@

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
  • parent_channelipc
  • +
  • parent_channel_kindipc
  • writeipc
  • write_closeipc
  • +
  • readipc
  • +
  • send_fdipc
  • +
  • recv_fdipc
  • parse_rawjson
  • last_errorjson
  • stringify_rawjson
  • @@ -2134,6 +2143,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..e35772582 100644 --- a/docs/api/ipc.html +++ b/docs/api/ipc.html @@ -16,9 +16,18 @@

    Aether

    Exports


    Modules

    @@ -118,25 +127,54 @@

    Modules

    ipc

    -

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

    +

    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 (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.

    +

    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

    +
    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

    parent_channel fn

    parent_channel() -> int

    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
    +

    "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 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 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

    @@ -144,6 +182,24 @@

    write_close fn

    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.

    +
    +

    read fn

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

    ch: int, max: int

    +

    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

    +

    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 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/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/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 ee9ca2f8c..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`. | 4 | [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) | @@ -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 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 `""` @@ -3155,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 new file mode 100644 index 000000000..89ef01637 --- /dev/null +++ b/new_changelogs/20261030T220938Z-added-ipc-socket-fd-passing.md @@ -0,0 +1,20 @@ +- **`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/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/runtime/utils/aether_spawn.c b/runtime/utils/aether_spawn.c index 90fc263ef..8bbaa2ca1 100644 --- a/runtime/utils/aether_spawn.c +++ b/runtime/utils/aether_spawn.c @@ -10,6 +10,9 @@ #include #include #include +#if AETHER_HAS_NETWORKING +#include +#endif #include #include #ifdef __APPLE__ @@ -132,6 +135,37 @@ 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]) { +#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 + 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 #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 7f04496c7..291e2dea4 100644 --- a/std/ipc/README.md +++ b/std/ipc/README.md @@ -1,11 +1,24 @@ # std.ipc -A back-channel to the parent process. +A channel to the parent process: a report pipe, or a two-way socket that +can also pass open file descriptors. -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. +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. + +## Two kinds of channel + +| 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 | + +`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. 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. @@ -14,8 +27,6 @@ the channel, and outside one `parent_channel` reports that there is none. 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") @@ -33,17 +44,98 @@ main() { } ``` -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. +## The socket channel + +`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. `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 + 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`, `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..886476dc7 100644 --- a/std/ipc/aether_ipc.c +++ b/std/ipc/aether_ipc.c @@ -1,38 +1,141 @@ -/* aether_ipc.c — child-side IPC primitives. +/* 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 (parent side). Provides the - * function the child calls to discover the back-channel fd that - * the parent opened. + * 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 the pipe's write end to + * 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 writable. Returns fd or -1. + * 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). * - * 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. */ + * 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 "../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; } -#else +const char* ipc_parent_channel_kind_raw(void) { return ""; } +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; + return ipc_read_failed("ipc.read: unavailable in this build"); +} +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; + return ipc_recv_failed("ipc.recv_fd: unavailable in this build"); +} + +#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 +#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: 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. */ +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; @@ -44,7 +147,236 @@ int ipc_parent_channel_raw(void) { return fd; } -#else /* _WIN32 */ +const char* ipc_parent_channel_kind_raw(void) { + if (ipc_parent_channel_raw() < 0) return ""; + const char* k = getenv("AETHER_IPC_KIND"); + if (k && strcmp(k, "socket") == 0) return "socket"; + return "pipe"; +} + +/* 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 (is_socket && errno == ENOTSOCK) { + is_socket = 0; + continue; + } + return ipc_errno_msg(what, errno); + } + if (n == 0) return ipc_errno_msg(what, EIO); + done += (size_t)n; + } + return ""; +} + +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) 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); + return ipc_read_failed(ipc_errno_msg("ipc.read", e)); + } + 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; +} + +#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 iovec iov; + 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; + msg.msg_control = control.buf; + msg.msg_controllen = sizeof control.buf; + struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); + + 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 ""; +} + +static void ipc_close_all(const int* fds, size_t n) { + for (size_t i = 0; i < n; i++) close(fds[i]); +} + +/* 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 = 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; + + int flags = 0; +#ifdef MSG_CMSG_CLOEXEC + flags |= MSG_CMSG_CLOEXEC; +#endif + 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)); + } + + /* 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; + /* 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 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); + if (nfds < IPC_MAX_FDS) fds[nfds++] = fd; + else close(fd); + } + } + + 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 (err) { + ipc_close_all(fds, nfds); + free(buf); + return ipc_recv_failed(err); + } + +#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(fds[0]); + return ipc_recv_failed("ipc.recv_fd: out of memory"); + } + _tuple_int_string_string out = { fds[0], (const char*)s, "" }; + return out; +} +#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)"; +} +_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 */ + +#else /* _WIN32 */ + +#include int ipc_parent_channel_raw(void) { /* Windows handle inheritance for fd-mapped pipes is @@ -56,6 +388,54 @@ int ipc_parent_channel_raw(void) { return -1; } -#endif +const char* ipc_parent_channel_kind_raw(void) { return ""; } + +/* 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 = 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; + } + return ""; +} + +_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) 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) { + (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; + return ipc_recv_failed("ipc.recv_fd: unsupported on Windows"); +} -#endif /* AETHER_HAS_FILESYSTEM */ +#endif /* AETHER_HAS_FILESYSTEM / _WIN32 */ diff --git a/std/ipc/module.ae b/std/ipc/module.ae index 843a43957..150a65bb9 100644 --- a/std/ipc/module.ae +++ b/std/ipc/module.ae @@ -1,36 +1,36 @@ -// std.ipc — child-to-parent back-channel IPC. +// 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 (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 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. // // 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). // -// Three primitives: +// Primitives: // -// parent_channel() -> int find fd or -1 -// write(fd, bytes) -> string write; "" or err -// write_close(fd, bytes) -> string write + close; "" or err +// 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 // -// (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 +// (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.) // -// `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. +// 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: // @@ -41,40 +41,47 @@ // err = ipc.write_close(ch, "passed=44\nfailed=3\n") // } -import std.net +import std.io 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, + 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. +// 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) // 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() } -// 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). +// "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 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 { - 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 @@ -90,6 +97,32 @@ write(fd: int, bytes: string) -> string { // 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 } + +// 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) +} + +// 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 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 new file mode 100644 index 000000000..af8a2c914 --- /dev/null +++ b/std/ipc/test_ipc.ae @@ -0,0 +1,188 @@ +// 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.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 +} + +// 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 +} + +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") + } + } + + 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) + } + + 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) + } + } + + 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) + } + } + + 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) + } + } + + return spec.run_summary(fw) +} diff --git a/std/os/aether_os.c b/std/os/aether_os.c index 84b476455..e81460728 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" }; @@ -286,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; @@ -1866,12 +1892,37 @@ static char** aether_ipc_env(char** base) { 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. +/* 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); +} + +/* 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 = aether_os_strerror_msg("os.socketpair", errno); + return out; + } + out._0 = fds[0]; + out._1 = fds[1]; + 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_read_fd. + * 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 @@ -1882,8 +1933,9 @@ static char** aether_ipc_env(char** base) { * 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) { + * 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) { out._2 = "null prog"; @@ -1894,16 +1946,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 = aether_os_strerror_msg("os.run_socket: socketpair", errno); + 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 +1970,23 @@ _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); + + /* 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; 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 +1997,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 +2006,41 @@ _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. */ + /* Child — wire chanfd[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 +3997,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/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 new file mode 100644 index 000000000..ba96e8168 --- /dev/null +++ b/tests/integration/std_ipc_socket/child.ae @@ -0,0 +1,59 @@ +// 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}'") } + + req, rerr = ipc.read(ch, 100) + if rerr != "" { fail("read request: ${rerr}") } + if req != "hello child" { fail("request was '${req}'") } + werr = ipc.write(ch, "hello parent") + if werr != "" { fail("write reply: ${werr}") } + + 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_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) + io.fd_close(fd) + werr2 = ipc.write(ch, "content: ${content}") + 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..2203887d0 --- /dev/null +++ b/tests/integration/std_ipc_socket/hostile_peer.c @@ -0,0 +1,71 @@ +/* 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 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 + * 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 new file mode 100644 index 000000000..7246748f8 --- /dev/null +++ b/tests/integration/std_ipc_socket/probe.ae @@ -0,0 +1,149 @@ +// 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 +import std.spec + +const CHILD_SRC = "tests/integration/std_ipc_socket/child.ae" +const BIG = 1048576 + +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, code, err = os.run_capture("build/ae", argv, null) + list.free(argv) + if err != "" { return "spawn error: ${err}" } + if code != 0 { return "ae build exit ${code}: ${out}" } + return "" +} + +// 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 +} + +// 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 +} + +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, "more than one descriptor", "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") + } + } + + 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 new file mode 100644 index 000000000..f7be7a580 --- /dev/null +++ b/tests/integration/std_ipc_socket/test_std_ipc_socket.sh @@ -0,0 +1,57 @@ +#!/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: the std.ipc socket channel is POSIX-only" + exit 0 ;; +esac + +if [ ! -x "$AE" ]; then + echo " [SKIP] std_ipc_socket: ae not built" + exit 0 +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