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").
+
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