diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d88d9342..28e56c05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,6 +314,14 @@ jobs: name: Run full suite against zlib build (executes DEFLATE section [124]) run: cd tests && bash run_all_tests.sh + - if: needs.scope.outputs.code == 'true' + name: Build net variant + run: make net + + - if: needs.scope.outputs.code == 'true' + name: Run full suite against net build (executes network section [125]) + run: cd tests && bash run_all_tests.sh + - if: needs.scope.outputs.code == 'true' name: Compile-check LSP run: make lsp diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e8136b..2fab7b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added + +- **`ext_net`: raw TCP sockets as tape-recorded nondeterministic inputs + (#414).** Seven builtins — `net_listen` / `net_port` / `net_accept` / + `net_dial` / `net_recv` / `net_send` / `net_close`, with timeouts — + behind `make net` (`EIGENSCRIPT_EXT_NET=1`, in no default build; also + compiled into `make asan-http` so the sanitizer gate covers it). The + differentiating contract: every nondeterministic outcome (accepted + connections, received bytes, bytes-sent counts, dial results, + kernel-assigned ephemeral ports) enters through the trace-tape seam, + so a recorded session replays **byte-identically with no network + present** — the replay run performs zero socket-family syscalls + (strace-verified; suite section [125] pins the byte-diff and the + tape's N-record count). Environment failures are recorded *values* + (`null` / `-1` / empty buffer), never live-path-only raises, so a + `catch` cannot desync replay; `net_send` is a suppressed recorded + write (the `mkdir` rule, documented in docs/TRACE.md as the deliberate + contrast to the #148 subprocess family). Sockets live in the process + handle table (`HANDLE_NET`) and are closed by `handle_table_drain` at + exit. `examples/net_echo.eigs` is a single-threaded both-ends echo + session over the loopback backlog. TCP only for now — UDP stays open + in #414. + ### Changed - **`-Werror=switch` was paid for in `CFLAGS` and then opted out of at almost diff --git a/Makefile b/Makefile index 48107cf9..730d597b 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ BINARY := $(SRC_DIR)/eigenscript # --step tape-stepper, stdio+isatty, same footing). CLI_ONLY := $(SRC_DIR)/main.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c $(SRC_DIR)/bundle.c -FULL_SOURCES := $(SOURCES) $(SRC_DIR)/ext_http.c $(SRC_DIR)/ext_db.c \ +FULL_SOURCES := $(SOURCES) $(SRC_DIR)/ext_http.c $(SRC_DIR)/ext_db.c $(SRC_DIR)/ext_net.c \ $(SRC_DIR)/model_io.c $(SRC_DIR)/model_infer.c $(SRC_DIR)/model_train.c PREFIX := $(HOME)/.local @@ -39,7 +39,7 @@ PREFIX := $(HOME)/.local LSP_SOURCES := $(SRC_DIR)/eigenlsp.c $(filter-out $(CLI_ONLY),$(SOURCES)) LSP_BINARY := $(SRC_DIR)/eigenlsp -.PHONY: all build full http gfx zlib lib amalgamation tsan test install install-gfx clean coverage coverage-clean fuzz fuzz-run lsp jit-smoke embed-smoke asan valgrind pgo freestanding-check freestanding-libc-diff asan-http print-% +.PHONY: all build full http net gfx zlib lib amalgamation tsan test install install-gfx clean coverage coverage-clean fuzz fuzz-run lsp jit-smoke embed-smoke asan valgrind pgo freestanding-check freestanding-libc-diff asan-http print-% # Introspection helper: `make print-SOURCES` echoes a variable's value. # tests/test_leak_guard.sh derives its ASan build source list from the @@ -62,6 +62,7 @@ build: full: $(CC) $(CFLAGS) -o $(BINARY) $(FULL_SOURCES) \ -I/usr/include/postgresql \ + -DEIGENSCRIPT_EXT_NET=1 \ -DEIGENSCRIPT_VERSION='"$(VERSION)"' \ $(LDFLAGS) -lpq @echo "EigenScript $(VERSION) (full) built. Binary: $$(du -sh $(BINARY) | cut -f1)" @@ -93,6 +94,18 @@ zlib: $(LDFLAGS) -lz @echo "EigenScript $(VERSION) (zlib) built. Binary: $$(du -sh $(BINARY) | cut -f1)" +# Raw TCP sockets on the trace tape (#414). Same opt-in pattern as gfx: +# in no default build, no extra library needed (plain POSIX sockets). +net: + $(CC) $(CFLAGS) -o $(BINARY) $(SOURCES) $(SRC_DIR)/ext_net.c \ + -DEIGENSCRIPT_EXT_HTTP=0 \ + -DEIGENSCRIPT_EXT_MODEL=0 \ + -DEIGENSCRIPT_EXT_DB=0 \ + -DEIGENSCRIPT_EXT_NET=1 \ + -DEIGENSCRIPT_VERSION='"$(VERSION)"' \ + $(LDFLAGS) + @echo "EigenScript $(VERSION) (net) built. Binary: $$(du -sh $(BINARY) | cut -f1)" + gfx: $(CC) $(CFLAGS) -o $(BINARY) $(SOURCES) $(SRC_DIR)/ext_gfx.c \ -DEIGENSCRIPT_EXT_HTTP=0 \ @@ -204,14 +217,15 @@ asan: # make asan-http && cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh asan-http: $(CC) -fsanitize=address,undefined -Werror=switch -g -O1 -o $(BINARY) $(SOURCES) \ - $(SRC_DIR)/ext_http.c \ + $(SRC_DIR)/ext_http.c $(SRC_DIR)/ext_net.c \ $(SRC_DIR)/model_io.c $(SRC_DIR)/model_infer.c $(SRC_DIR)/model_train.c \ -DEIGENSCRIPT_EXT_HTTP=1 \ -DEIGENSCRIPT_EXT_MODEL=1 \ -DEIGENSCRIPT_EXT_DB=0 \ + -DEIGENSCRIPT_EXT_NET=1 \ -DEIGENSCRIPT_VERSION='"$(VERSION)"' \ -lm -lpthread - @echo "EigenScript $(VERSION) (asan+ubsan, http+model) built. Binary: $(BINARY)" + @echo "EigenScript $(VERSION) (asan+ubsan, http+model+net) built. Binary: $(BINARY)" # ThreadSanitizer build for the concurrency race gate (tests/test_tsan.sh). # Complements ASan (which is not run with the thread checker). Run the tests diff --git a/README.md b/README.md index 09b03ebc..b430f5dd 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ get made and how contributors can earn commit access over time. make # build make test # build and run the full suite (2,500+ checks) make gfx # build with SDL2 graphics (UI toolkit, games) +make net # build with raw TCP sockets (record/replay-able) make install # install to ~/.local/bin make clean # remove build artifacts ``` diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index b5abaccf..48e72498 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -498,6 +498,35 @@ automatically at exit. |------|-----------|-------------| | `build_corpus` | `build_corpus of [files, top_n, stream_path, vocab_path]` | Three-pass C-backed corpus builder: tokenise `files`, emit top-`n` vocabulary and stream-format token IDs | +## Optional: Network Extension (TCP sockets) + +Requires a `make net` build (`EIGENSCRIPT_EXT_NET=1`; in no default +build). Raw TCP sockets whose every nondeterministic outcome — accepted +connections, received bytes, bytes-sent counts, dial results, +kernel-assigned ports — rides the trace tape: a session recorded under +`EIGS_TRACE` replays byte-identically under `EIGS_REPLAY` with **no +network present** (the replay run performs zero socket syscalls). See +[TRACE.md](TRACE.md). + +| Builtin | Form | Returns | +|---------|------|---------| +| `net_listen` | `net_listen of port` | listener handle, or `null` (bind failed). Port `0` = kernel-assigned ephemeral port | +| `net_port` | `net_port of listener` | the locally bound port (the kernel's pick for port 0), or `null` | +| `net_accept` | `net_accept of listener` / `net_accept of [listener, timeout_ms]` | connection handle, or `null` on timeout | +| `net_dial` | `net_dial of [host, port]` / `net_dial of [host, port, timeout_ms]` | connection handle, or `null` (refused / unresolvable / timeout) | +| `net_recv` | `net_recv of [conn, max_bytes]` / `net_recv of [conn, max_bytes, timeout_ms]` | buffer of byte values (empty buffer = connection over), or `null` on timeout. `max_bytes` is clamped to 8192 per call — loop to drain; decode text with `str_from_bytes` | +| `net_send` | `net_send of [conn, data]` — `data` is a string, buffer, or byte list | bytes sent, or `-1` (peer gone / bad handle) | +| `net_close` | `net_close of handle` | `null`; idempotent | + +Environment failures are *values* (`null` / `-1` / empty buffer), never +raises, so every outcome lands on the tape and a `catch` cannot desync +replay; argument-shape mistakes (wrong type or arity) raise +deterministically. A single-threaded program can be both ends of a +connection: on loopback, `net_dial` completes against the listen +backlog before `net_accept` runs (see `examples/net_echo.eigs`). +Sockets left open at exit are closed by the runtime's handle-table +drain. UDP is not yet exposed (#414 tracks it). + ## Optional: HTTP Extension Requires full build. Provides an embedded HTTP server. diff --git a/docs/TRACE.md b/docs/TRACE.md index d22b8e24..94f75cf5 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -75,6 +75,27 @@ perspective lands on the tape as an `N` record: the live argv; #471) - **HTTP extension:** `http_post` (success and all error paths), `http_request_body`, `http_session_id`, `http_request_headers` +- **Network extension (#414):** `net_listen`, `net_port`, `net_accept`, + `net_dial`, `net_recv`, `net_send`. Every environment outcome is a + recorded *value* — `null` for failure/timeout, a number or buffer for + success — never a live-path-only raise, so a `catch` cannot desync + the record stream (the `read_bytes_buf` lesson). The whole family is + TAKE/RECORD-wrapped: under `EIGS_REPLAY` the tape is taken **before + any socket call** — the replay run creates, binds, connects, reads, + and writes **nothing** (verified by strace: zero socket-family + syscalls), which is what "replay last night's flaky network failure + with the network gone" means. `net_recv` caps at 8192 bytes per call + so every `N` record fits the 64 KiB budget, the same discipline as + `audio_capture_read`. `net_send` is a *recorded write*, like `mkdir`: + its observable effect on the program is its result (bytes sent), and + the peer's future responses are themselves on the tape — so under + replay the send is **suppressed** (recorded count served, nothing + written) and the replayed world stays consistent. That is the + deliberate contrast with the #148 subprocess family below: a + `proc_write` feeds a live child whose behavior the tape does not pin, + so suppressing it would be meaningless. (`net_close` is deterministic + and untraced — under replay no socket exists and it is a natural + no-op, the `audio_capture_close` shape.) - **Audio capture (gfx extension, #579):** `audio_capture_open`, `audio_capture_read`. Captured audio is a device input, so the whole capture chain is TAKE/RECORD-wrapped: under `EIGS_REPLAY` the tape is diff --git a/examples/net_echo.eigs b/examples/net_echo.eigs new file mode 100644 index 00000000..e55db3d9 --- /dev/null +++ b/examples/net_echo.eigs @@ -0,0 +1,46 @@ +# net_echo — raw TCP sockets on the trace tape (#414). +# Requires a `make net` build (the net_* builtins are in no default build). +# +# One process plays both ends of a TCP connection over loopback: the +# dial completes against the listen backlog, so no threads are needed. +# Every nondeterministic outcome — the kernel's ephemeral port, the +# accepted connection, the received bytes, the bytes-sent counts — +# enters through the trace tape, which makes this session replayable +# with no network at all: +# +# $ EIGS_TRACE=echo.tape eigenscript net_echo.eigs > first.out +# $ EIGS_REPLAY=echo.tape eigenscript net_echo.eigs > second.out +# $ diff first.out second.out # identical +# +# The replay run performs zero socket syscalls — the tape pins what the +# network said, so the network is no longer required to say it. + +# Ask the kernel for an ephemeral port (0), then read back its pick. +server is net_listen of 0 +port is net_port of server +print of "listening" + +# Dial ourselves. On loopback the connect lands in the listen backlog +# immediately, before we ever call accept. +client is net_dial of ["127.0.0.1", port] +conn is net_accept of [server, 2000] + +# Client -> server. net_recv returns a buffer of byte values (binary- +# safe); str_from_bytes decodes text. The 2000 is a timeout in ms — +# a timeout returns null, a closed peer returns an empty buffer. +sent is net_send of [client, "ping over the tape"] +print of sent +data is net_recv of [conn, 256, 2000] +msg is str_from_bytes of (data) +print of msg + +# Server echoes it back. +echoed is net_send of [conn, msg] +print of echoed +back is net_recv of [client, 256, 2000] +print of str_from_bytes of (back) + +net_close of client +net_close of conn +net_close of server +print of "echo complete" diff --git a/src/builtins.c b/src/builtins.c index aaa8345c..b7741143 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -25,6 +25,11 @@ #include "ext_db_internal.h" #endif +#if EIGENSCRIPT_EXT_NET +#include "ext_net_internal.h" +#include /* close() in handle_table_drain's HANDLE_NET pass */ +#endif + #if EIGENSCRIPT_EXT_MODEL #include "model_internal.h" #endif @@ -5834,6 +5839,20 @@ void handle_table_drain(EigsState *st) { free(ch); st->handle_table[i].ptr = NULL; } +#if EIGENSCRIPT_EXT_NET + /* #414 sockets: an outstanding listener/connection at exit is an OS fd + * plus one malloc'd row — close and free. No thread can still be using + * it: every worker was joined above. */ + for (int i = 1; i < HANDLE_TABLE_SIZE; i++) { + if (st->handle_table[i].type != HANDLE_NET) continue; + EigsNetSock *ns = (EigsNetSock*)st->handle_table[i].ptr; + if (!ns) continue; + close(ns->fd); + free(ns); + st->handle_table[i].ptr = NULL; + } +#endif + /* #408 tasks: cooperative, single-thread, no OS resource — just held * refs. An outstanding task at exit (never joined, or the program ended * with it still live) is reclaimed here. Its held entry_fn/args/result @@ -7598,6 +7617,10 @@ void register_builtins(Env *env) { register_db_builtins(env); #endif +#if EIGENSCRIPT_EXT_NET + register_net_builtins(env); +#endif + #if EIGENSCRIPT_EXT_MODEL register_model_builtins(env); #endif diff --git a/src/eigenscript.h b/src/eigenscript.h index 45381553..d95c2163 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -24,6 +24,11 @@ #ifndef EIGENSCRIPT_EXT_GFX #define EIGENSCRIPT_EXT_GFX 0 #endif +/* Raw TCP sockets on the trace tape (#414). Default OFF like GFX: in no + * default build — `make net` opts in (src/ext_net.c). */ +#ifndef EIGENSCRIPT_EXT_NET +#define EIGENSCRIPT_EXT_NET 0 +#endif /* DEFLATE codecs (inflate/deflate via the system zlib, -lz). Default OFF * like GFX: the minimal build stays zero-dependency — the four builtins * stay registered but raise "compiled without zlib support" until @@ -473,7 +478,8 @@ typedef enum { HANDLE_STORE, HANDLE_THREAD, HANDLE_CHANNEL, - HANDLE_TASK /* #408 cooperative task — id-keyed, drained at teardown */ + HANDLE_TASK, /* #408 cooperative task — id-keyed, drained at teardown */ + HANDLE_NET /* #414 ext_net socket (listener or connection) */ } HandleType; typedef struct { diff --git a/src/ext_names.h b/src/ext_names.h index 148a5878..dc63b577 100644 --- a/src/ext_names.h +++ b/src/ext_names.h @@ -97,6 +97,16 @@ X(http_serve, builtin_http_serve) \ X(http_cors, builtin_http_cors) +/* ---- ext_net.c: raw TCP sockets on the tape (EIGENSCRIPT_EXT_NET) ---- */ +#define EIGS_NET_BUILTINS(X) \ + X(net_listen, builtin_net_listen) \ + X(net_port, builtin_net_port) \ + X(net_accept, builtin_net_accept) \ + X(net_dial, builtin_net_dial) \ + X(net_recv, builtin_net_recv) \ + X(net_send, builtin_net_send) \ + X(net_close, builtin_net_close) + /* ---- ext_db.c: SQLite bridge (EIGENSCRIPT_EXT_DB) ---- */ #define EIGS_DB_BUILTINS(X) \ X(db_connect, builtin_db_connect) \ diff --git a/src/ext_net.c b/src/ext_net.c new file mode 100644 index 00000000..eaf63f79 --- /dev/null +++ b/src/ext_net.c @@ -0,0 +1,354 @@ +/* + * EigenScript Network Extension — raw TCP sockets as tape-recorded + * nondeterministic inputs (#414). + * Compiled only when EIGENSCRIPT_EXT_NET=1 (in no default build). + * + * The differentiating contract: every nondeterministic outcome — + * accepted connections, received bytes, bytes-sent counts, dial + * results, kernel-assigned ports — enters the program through the + * trace-tape seam (TRACE_NONDET_TAKE/RECORD), so a recorded session + * replays byte-identically with no network present at all. Under + * EIGS_REPLAY the TAKE short-circuits before any socket call: no + * socket is created, bound, connected, read, or written. + * + * Replay-determinism rule (the #601 lesson): every ENVIRONMENT outcome + * is a recorded *value* — null for failure/timeout, a number or buffer + * for success — never a live-path-only raise. A raise that only the + * live run takes would desync the tape's N-record stream the moment a + * `catch` swallows it. Argument-SHAPE errors (wrong type/arity) raise + * deterministically BEFORE the tape boundary: both runs take the same + * path and consume no record. + * + * net_send is the deliberate contrast to the #148 non-replayable + * subprocess family: a send's observable effect on the program is its + * result (bytes sent), and the peer's future responses are themselves + * recorded — so under replay the send is SUPPRESSED (recorded result + * served, nothing written) and the world stays consistent. proc_write + * cannot make that claim: its side effects feed a live child the tape + * does not pin. + */ + +#include "ext_net_internal.h" +#include "ext_names.h" +#include "trace.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Per-call receive cap. An N record has a 64 KiB byte budget and a + * byte serializes as up to 4 chars ("255,") — 8192 bytes stays well + * inside it. A truncated record would silently fall back to the live + * source on replay, exactly the failure audio_capture_read's 2048- + * sample cap exists to prevent. Loop to drain more. */ +#define NET_RECV_MAX 8192 + +/* Listen backlog. Dial-before-accept relies on it: on loopback a + * connect completes as soon as the kernel queues it here, which is + * what lets a single-threaded program be both ends of a socket. */ +#define NET_BACKLOG 16 + +/* ---- helpers ------------------------------------------------------ */ + +static Value* net_bytes_to_buffer(const unsigned char *bytes, int n) { + Value *v = xcalloc(1, sizeof(Value)); + v->type = VAL_BUFFER; + v->refcount = 1; + v->data.buffer.count = n; + v->data.buffer.data = xcalloc(n > 0 ? (size_t)n : 1, sizeof(double)); + for (int i = 0; i < n; i++) + v->data.buffer.data[i] = (double)bytes[i]; + return v; +} + +static int net_register_sock(int fd, EigsNetSockKind kind) { + EigsNetSock *s = xmalloc(sizeof(EigsNetSock)); + s->fd = fd; + s->kind = kind; + int id = handle_register(s, HANDLE_NET); + if (id < 0) { close(fd); free(s); } + return id; +} + +static EigsNetSock* net_lookup(Value *v, EigsNetSockKind kind) { + if (!v || v->type != VAL_NUM) return NULL; + EigsNetSock *s = handle_lookup((int)v->data.num, HANDLE_NET); + if (!s || s->kind != kind) return NULL; + return s; +} + +/* poll() one fd for readability. timeout_ms < 0 blocks. Returns 1 when + * readable, 0 on timeout/error. */ +static int net_wait_readable(int fd, int timeout_ms) { + struct pollfd p = { .fd = fd, .events = POLLIN }; + int r; + do { r = poll(&p, 1, timeout_ms); } while (r < 0 && errno == EINTR); + return r > 0; +} + +/* Deterministic shape check shared by the list-form builtins. rt_error + * RETURNS (the VM's CHECK_ERROR unwinds after the builtin exits), so a + * failed check must make the caller return immediately — falling + * through would deref a non-list. Returns 1 ok / 0 raised. Runs before + * the tape boundary: both runs take the same path, no record consumed. */ +static int net_require_list(Value *arg, int min, int max, const char *fn) { + if (!arg || arg->type != VAL_LIST || arg->data.list.count < min + || arg->data.list.count > max) { + rt_error(EK_TYPE, 0, "%s: expected %d-%d arguments (see docs/BUILTINS.md)", + fn, min, max); + return 0; + } + return 1; +} + +static int net_num_at(Value *arg, int idx, int fallback) { + if (!arg || arg->type != VAL_LIST || idx >= arg->data.list.count) + return fallback; + Value *v = arg->data.list.items[idx]; + return (v && v->type == VAL_NUM) ? (int)v->data.num : fallback; +} + +/* ---- builtins ----------------------------------------------------- */ + +/* net_listen of port → listener handle id, or null when the bind/listen + * failed (port taken, privileged port). Port 0 asks the kernel for an + * ephemeral port — read it back with net_port. Binds 0.0.0.0, matching + * ext_http's server posture (SECURITY.md: the script is trusted). */ +Value* builtin_net_listen(Value *arg) { + if (!arg || arg->type != VAL_NUM) { + rt_error(EK_TYPE, 0, "net_listen: expected a port number"); + return make_null(); + } + TRACE_NONDET_TAKE("net_listen"); + int port = (int)arg->data.num; + if (port < 0 || port > 65535) + TRACE_NONDET_RECORD("net_listen", make_null()); + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) TRACE_NONDET_RECORD("net_listen", make_null()); + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)port); + if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0 || + listen(fd, NET_BACKLOG) < 0) { + close(fd); + TRACE_NONDET_RECORD("net_listen", make_null()); + } + int id = net_register_sock(fd, NET_SOCK_LISTENER); + if (id < 0) TRACE_NONDET_RECORD("net_listen", make_null()); + TRACE_NONDET_RECORD("net_listen", make_num(id)); +} + +/* net_port of listener_id → the locally bound port (the kernel's pick + * for a port-0 listen), or null on a bad/closed handle. Kernel-chosen, + * so it rides the tape like every other environment outcome. */ +Value* builtin_net_port(Value *arg) { + if (!arg || arg->type != VAL_NUM) { + rt_error(EK_TYPE, 0, "net_port: expected a listener handle"); + return make_null(); + } + TRACE_NONDET_TAKE("net_port"); + EigsNetSock *s = net_lookup(arg, NET_SOCK_LISTENER); + if (!s) TRACE_NONDET_RECORD("net_port", make_null()); + struct sockaddr_in addr; + socklen_t len = sizeof(addr); + if (getsockname(s->fd, (struct sockaddr*)&addr, &len) < 0) + TRACE_NONDET_RECORD("net_port", make_null()); + TRACE_NONDET_RECORD("net_port", make_num(ntohs(addr.sin_port))); +} + +/* net_accept of listener_id + * net_accept of [listener_id, timeout_ms] + * → connection handle id, or null on timeout / bad handle. No timeout + * blocks indefinitely. */ +Value* builtin_net_accept(Value *arg) { + int timeout_ms = -1; + if (arg && arg->type == VAL_LIST) { + if (!net_require_list(arg, 2, 2, "net_accept")) return make_null(); + timeout_ms = net_num_at(arg, 1, -1); + arg = arg->data.list.items[0]; + } + if (!arg || arg->type != VAL_NUM) { + rt_error(EK_TYPE, 0, "net_accept: expected a listener handle"); + return make_null(); + } + TRACE_NONDET_TAKE("net_accept"); + EigsNetSock *s = net_lookup(arg, NET_SOCK_LISTENER); + if (!s) TRACE_NONDET_RECORD("net_accept", make_null()); + if (!net_wait_readable(s->fd, timeout_ms)) + TRACE_NONDET_RECORD("net_accept", make_null()); + int cfd; + do { cfd = accept(s->fd, NULL, NULL); } while (cfd < 0 && errno == EINTR); + if (cfd < 0) TRACE_NONDET_RECORD("net_accept", make_null()); + int id = net_register_sock(cfd, NET_SOCK_CONN); + if (id < 0) TRACE_NONDET_RECORD("net_accept", make_null()); + TRACE_NONDET_RECORD("net_accept", make_num(id)); +} + +/* net_dial of [host, port] + * net_dial of [host, port, timeout_ms] + * → connection handle id, or null on refusal / resolution failure / + * timeout. The connect runs nonblocking + poll so the timeout is real; + * the socket is restored to blocking afterward (recv/send poll first). */ +Value* builtin_net_dial(Value *arg) { + if (!net_require_list(arg, 2, 3, "net_dial")) return make_null(); + Value *host = arg->data.list.items[0]; + if (!host || host->type != VAL_STR) { + rt_error(EK_TYPE, 0, "net_dial: expected [host, port] with a string host"); + return make_null(); + } + int port = net_num_at(arg, 1, -1); + int timeout_ms = net_num_at(arg, 2, -1); + TRACE_NONDET_TAKE("net_dial"); + if (port < 0 || port > 65535) TRACE_NONDET_RECORD("net_dial", make_null()); + + char portstr[8]; + snprintf(portstr, sizeof(portstr), "%d", port); + struct addrinfo hints = {0}, *res = NULL; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(host->data.str, portstr, &hints, &res) != 0 || !res) + TRACE_NONDET_RECORD("net_dial", make_null()); + + int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (fd < 0) { freeaddrinfo(res); TRACE_NONDET_RECORD("net_dial", make_null()); } + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int rc = connect(fd, res->ai_addr, res->ai_addrlen); + freeaddrinfo(res); + if (rc < 0 && errno == EINPROGRESS) { + struct pollfd p = { .fd = fd, .events = POLLOUT }; + int pr; + do { pr = poll(&p, 1, timeout_ms); } while (pr < 0 && errno == EINTR); + int soerr = 0; + socklen_t slen = sizeof(soerr); + if (pr <= 0 || + getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen) < 0 || soerr) { + close(fd); + TRACE_NONDET_RECORD("net_dial", make_null()); + } + } else if (rc < 0) { + close(fd); + TRACE_NONDET_RECORD("net_dial", make_null()); + } + fcntl(fd, F_SETFL, flags); + int id = net_register_sock(fd, NET_SOCK_CONN); + if (id < 0) TRACE_NONDET_RECORD("net_dial", make_null()); + TRACE_NONDET_RECORD("net_dial", make_num(id)); +} + +/* net_recv of [conn_id, max_bytes] + * net_recv of [conn_id, max_bytes, timeout_ms] + * → buffer of byte values (empty buffer = orderly EOF or reset — the + * connection is over), or null on timeout / bad handle. max_bytes is + * clamped to 8192 per call (the N-record budget); loop to drain more. + * Convert text with str_from_bytes. */ +Value* builtin_net_recv(Value *arg) { + if (!net_require_list(arg, 2, 3, "net_recv")) return make_null(); + Value *conn = arg->data.list.items[0]; + int max = net_num_at(arg, 1, 0); + int timeout_ms = net_num_at(arg, 2, -1); + /* A zero-byte recv() returns 0 — indistinguishable from EOF — so a + * senseless max is a shape error, decided before the tape boundary. */ + if (max < 1) { + rt_error(EK_VALUE, 0, "net_recv: max_bytes must be >= 1"); + return make_null(); + } + TRACE_NONDET_TAKE("net_recv"); + EigsNetSock *s = net_lookup(conn, NET_SOCK_CONN); + if (!s) TRACE_NONDET_RECORD("net_recv", make_null()); + if (max > NET_RECV_MAX) max = NET_RECV_MAX; + if (!net_wait_readable(s->fd, timeout_ms)) + TRACE_NONDET_RECORD("net_recv", make_null()); + unsigned char buf[NET_RECV_MAX]; + ssize_t n; + do { n = recv(s->fd, buf, (size_t)max, 0); } while (n < 0 && errno == EINTR); + if (n < 0) n = 0; /* reset == connection over == EOF shape */ + TRACE_NONDET_RECORD("net_recv", net_bytes_to_buffer(buf, (int)n)); +} + +/* net_send of [conn_id, data] — data is a string or a buffer/list of + * byte values. → number of bytes sent, or -1 on a bad handle / peer + * gone. Loops until everything is written. Under EIGS_REPLAY the send + * is suppressed: the recorded count is served and nothing touches a + * socket (see the header comment for why that is sound here and not + * for proc_write). */ +Value* builtin_net_send(Value *arg) { + if (!net_require_list(arg, 2, 2, "net_send")) return make_null(); + Value *conn = arg->data.list.items[0]; + Value *data = arg->data.list.items[1]; + if (!data || (data->type != VAL_STR && data->type != VAL_BUFFER + && data->type != VAL_LIST)) { + rt_error(EK_TYPE, 0, "net_send: data must be a string, buffer, or byte list"); + return make_null(); + } + TRACE_NONDET_TAKE("net_send"); + EigsNetSock *s = net_lookup(conn, NET_SOCK_CONN); + if (!s) TRACE_NONDET_RECORD("net_send", make_num(-1)); + + const unsigned char *bytes; + unsigned char *owned = NULL; + size_t len; + if (data->type == VAL_STR) { + bytes = (const unsigned char*)data->data.str; + len = strlen(data->data.str); + } else { + int n = data->type == VAL_BUFFER ? data->data.buffer.count + : data->data.list.count; + owned = xmalloc(n > 0 ? (size_t)n : 1); + for (int i = 0; i < n; i++) { + double dv = data->type == VAL_BUFFER + ? data->data.buffer.data[i] + : (data->data.list.items[i] && + data->data.list.items[i]->type == VAL_NUM + ? data->data.list.items[i]->data.num : 0.0); + owned[i] = (unsigned char)((int)dv & 0xFF); + } + bytes = owned; + len = (size_t)n; + } + + size_t sent = 0; + while (sent < len) { + ssize_t n = send(s->fd, bytes + sent, len - sent, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { free(owned); TRACE_NONDET_RECORD("net_send", make_num(-1)); } + sent += (size_t)n; + } + free(owned); + TRACE_NONDET_RECORD("net_send", make_num((double)sent)); +} + +/* net_close of handle_id → null. Deterministic and untraced: closing is + * idempotent bookkeeping, and under EIGS_REPLAY no handle exists so it + * is a natural no-op (the audio_capture_close shape). */ +Value* builtin_net_close(Value *arg) { + if (!arg || arg->type != VAL_NUM) { + rt_error(EK_TYPE, 0, "net_close: expected a socket handle"); + return make_null(); + } + EigsNetSock *s = handle_lookup((int)arg->data.num, HANDLE_NET); + if (s) { + close(s->fd); + free(s); + handle_release((int)arg->data.num); + } + return make_null(); +} + +/* ---- registration -------------------------------------------------- */ + +void register_net_builtins(Env *env) { +#define X(name, fn) env_set_local_owned(env, #name, make_builtin(fn)); + EIGS_NET_BUILTINS(X) +#undef X +} diff --git a/src/ext_net_internal.h b/src/ext_net_internal.h new file mode 100644 index 00000000..bc02e15d --- /dev/null +++ b/src/ext_net_internal.h @@ -0,0 +1,26 @@ +/* + * EigenScript Network Extension — private header. + * Included by ext_net.c, and by builtins.c (under EIGENSCRIPT_EXT_NET) + * for the handle_table_drain teardown pass. Deliberately free of socket + * headers: the drain pass only needs the fd. + */ + +#ifndef EXT_NET_INTERNAL_H +#define EXT_NET_INTERNAL_H + +#include "eigenscript.h" + +/* One row per live socket, owned by the process handle table + * (HANDLE_NET). Listeners and connections share the struct; `kind` + * keeps net_accept off a connection and net_recv/net_send off a + * listener. */ +typedef enum { NET_SOCK_LISTENER, NET_SOCK_CONN } EigsNetSockKind; + +typedef struct { + int fd; + EigsNetSockKind kind; +} EigsNetSock; + +void register_net_builtins(Env *env); + +#endif diff --git a/src/lint.c b/src/lint.c index 918a1f16..abc8f6b1 100644 --- a/src/lint.c +++ b/src/lint.c @@ -156,6 +156,7 @@ static int is_builtin_name(const char *name) { EIGS_HTTP_REQUEST_BUILTINS(X) EIGS_DB_BUILTINS(X) EIGS_MODEL_BUILTINS(X) + EIGS_NET_BUILTINS(X) #undef X if (!env_get(e, "report_value")) env_set_local_owned(e, "report_value", make_null()); @@ -2958,6 +2959,7 @@ static void check_undefined_names(ASTNode *ast, const char *path, EIGS_HTTP_REQUEST_BUILTINS(X) EIGS_DB_BUILTINS(X) EIGS_MODEL_BUILTINS(X) + EIGS_NET_BUILTINS(X) #undef X /* Names the compiler resolves itself, so no registrar ever binds them: * `report_value of x` is a special form (#294), `trajectory of x` is one diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 8b979948..673ed747 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2626,6 +2626,65 @@ else echo "" fi +# [125] ext_net TCP sockets on the trace tape (#414) — probe-gated like +# [44] HTTP: the net_* builtins exist only under `make net` / `make +# asan-http` (in no default build), so a default binary skips cleanly. +# Two parts: the loopback echo suite, then the definition-of-done — +# the same file recorded under EIGS_TRACE must replay byte-identically +# under EIGS_REPLAY (the tape pins every socket outcome; the replay run +# performs zero socket syscalls). The N-record count is pinned: an +# unexplained change in tape accounting is a regression, not noise. +NET_PROBE_FILE=$(mktemp /tmp/eigs_net_probe_XXXXXX.eigs) +cat > "$NET_PROBE_FILE" <<'PROBE' +print of net_close +PROBE +NET_PROBE_OUT=$(./eigenscript "$NET_PROBE_FILE" 2>&1) +rm -f "$NET_PROBE_FILE" + +if ! echo "$NET_PROBE_OUT" | grep -q "ndefined variable"; then + echo "[125] Network Extension (#414, 25 checks + record/replay)" + NET_OUTPUT=$(./eigenscript ../tests/test_net.eigs 2>&1); NET_OUTPUT_RC=$? + if rc_ok "$NET_OUTPUT_RC" "$NET_OUTPUT" && echo "$NET_OUTPUT" | grep -q "All net tests passed"; then + TOTAL=$((TOTAL + 25)) + PASS=$((PASS + 25)) + echo " PASS: all 25 net builtin checks" + else + TOTAL=$((TOTAL + 25)) + FAIL=$((FAIL + 25)) + echo " FAIL: net builtin tests" + echo "$NET_OUTPUT" | grep -iE "assert|error|FAIL" | head -5 + fi + NET_TAPE=$(mktemp /tmp/eigs_net_tape_XXXXXX) + NET_REC=$(EIGS_TRACE=$NET_TAPE ./eigenscript ../tests/test_net.eigs 2>&1); NET_REC_RC=$? + NET_REP=$(EIGS_REPLAY=$NET_TAPE ./eigenscript ../tests/test_net.eigs 2>&1); NET_REP_RC=$? + NET_NREC=$(grep -c '^N net_' "$NET_TAPE") + rm -f "$NET_TAPE" + TOTAL=$((TOTAL + 3)) + if [ "$NET_REC_RC" = "0" ] && echo "$NET_REC" | grep -q "All net tests passed"; then + PASS=$((PASS + 1)) + echo " PASS: records under EIGS_TRACE" + else + FAIL=$((FAIL + 1)) + echo " FAIL: record run broke (rc=$NET_REC_RC)" + fi + if [ "$NET_REP_RC" = "0" ] && [ "$NET_REP" = "$NET_REC" ]; then + PASS=$((PASS + 1)) + echo " PASS: replay is byte-identical with no network" + else + FAIL=$((FAIL + 1)) + echo " FAIL: replay diverged from record (rc=$NET_REP_RC)" + diff <(printf '%s\n' "$NET_REC") <(printf '%s\n' "$NET_REP") | head -5 + fi + if [ "$NET_NREC" = "17" ]; then + PASS=$((PASS + 1)) + echo " PASS: tape carries the pinned 17 net N records" + else + FAIL=$((FAIL + 1)) + echo " FAIL: tape N-record count $NET_NREC != 17 (accounting drift)" + fi + echo "" +fi + # [65] sort_by builtin echo "[65] Sort By (9 checks)" SBY_OUTPUT=$(./eigenscript ../tests/test_sort_by.eigs 2>&1); SBY_OUTPUT_RC=$? @@ -3549,7 +3608,9 @@ echo "" # [97] Example programs — every examples/*.eigs (and examples/stem/*.eigs) # must run to a clean exit. examples/errors/ is covered by [90]; the gfx -# demos (gfx_* builtins) need `make gfx` and are skipped on the default build. +# demos (gfx_* builtins) need `make gfx`, the net demos (net_listen) need +# `make net`, and both are skipped unconditionally here — their dedicated +# sections ([62], [125]) exercise them under the right build. # Each runs from its own directory (so relative paths resolve) with stdin # closed. rc_ok tolerates the spawn-thread LeakSanitizer floor; no non-gfx # example uses spawn, so this stays leak-clean. @@ -3562,7 +3623,7 @@ EIGS_ABS="$(pwd)/eigenscript" # (#616). $EIGS_TMO's generous budget keeps this a runaway backstop, not a perf # gate — a genuine hang still fails, a slow-but-working example does not. for f in $(find ../examples -name '*.eigs' -not -path '*/errors/*' | sort); do - if grep -q 'gfx_' "$f"; then EX_SKIP=$((EX_SKIP + 1)); continue; fi + if grep -qE 'gfx_|net_listen' "$f"; then EX_SKIP=$((EX_SKIP + 1)); continue; fi EX_OUT=$( cd "$(dirname "$f")" && $EIGS_TMO "$EIGS_ABS" "$(basename "$f")" &1 ); EX_RC=$? if [ "$EX_RC" = "124" ]; then echo " FAIL(124): $f (timed out after ${EIGS_TEST_TIMEOUT}s — runaway)" diff --git a/tests/test_net.eigs b/tests/test_net.eigs new file mode 100644 index 00000000..bb425501 --- /dev/null +++ b/tests/test_net.eigs @@ -0,0 +1,92 @@ +# ext_net TCP socket tests (#414). Loopback only — a single process is +# both ends: dial lands in the listen backlog before accept runs, which +# is what lets this stay single-threaded. Runs only when the binary is +# compiled with EIGENSCRIPT_EXT_NET=1 (gated by run_all_tests.sh). +# Section [125] additionally re-runs this file under EIGS_TRACE and +# EIGS_REPLAY and byte-diffs the outputs. + +# ---- listen on an ephemeral port ---- +lid is net_listen of 0 +assert of [(lid == null) == 0, "NET01 net_listen returns a handle"] +port is net_port of lid +assert of [port > 0, "NET02 net_port reports the kernel's ephemeral pick"] + +# ---- dial + accept ---- +cid is net_dial of ["127.0.0.1", port] +assert of [(cid == null) == 0, "NET03 net_dial connects via the backlog"] +sid is net_accept of [lid, 2000] +assert of [(sid == null) == 0, "NET04 net_accept returns a connection"] + +# ---- string send, byte-buffer recv, round-trip ---- +n1 is net_send of [cid, "hello, tape"] +assert of [n1 == 11, "NET05 net_send reports bytes sent"] +data is net_recv of [sid, 64, 2000] +assert of [(len of data) == 11, "NET06 net_recv returns the sent bytes"] +msg is str_from_bytes of (data) +assert of [msg == "hello, tape", "NET07 bytes decode back to the string"] + +# ---- echo back over the other direction ---- +n2 is net_send of [sid, msg] +assert of [n2 == 11, "NET08a echo send reports bytes sent"] +back is net_recv of [cid, 64, 2000] +assert of [(str_from_bytes of (back)) == "hello, tape", "NET08 echo round-trip"] + +# ---- byte-list send (binary-safe path) ---- +n3 is net_send of [cid, [7, 0, 255]] +assert of [n3 == 3, "NET09 net_send accepts a byte list"] +raw is net_recv of [sid, 64, 2000] +assert of [(len of raw) == 3, "NET10 binary bytes arrive intact"] +assert of [raw[0] == 7, "NET11 first byte"] +assert of [raw[1] == 0, "NET12 NUL byte survives (buffer, not string)"] +assert of [raw[2] == 255, "NET13 high byte"] + +# ---- timeouts are a distinct recorded outcome: null ---- +t1 is net_recv of [cid, 64, 50] +assert of [t1 == null, "NET14 recv timeout returns null"] +t2 is net_accept of [lid, 50] +assert of [t2 == null, "NET15 accept timeout returns null"] + +# ---- EOF: peer closed → empty buffer, not null ---- +net_close of cid +eof is net_recv of [sid, 64, 2000] +assert of [(len of eof) == 0, "NET16 recv after peer close is an empty buffer"] + +# ---- environment failures are values, not raises ---- +d1 is net_send of [sid, "into the void"] +assert of [(d1 == -1) or (d1 == 13), "NET17 send to a closed peer is -1 or last flush"] +net_close of sid +net_close of lid +gone is net_dial of ["127.0.0.1", port] +assert of [gone == null, "NET18 dial to a closed port returns null"] +r1 is net_recv of [sid, 64, 50] +assert of [r1 == null, "NET19 recv on a closed handle returns null"] +noname is net_dial of ["definitely-not-a-real-host.invalid", 80] +assert of [noname == null, "NET20 unresolvable host returns null"] + +# ---- net_close is idempotent and always null ---- +c1 is net_close of sid +assert of [c1 == null, "NET21 double close is a quiet null"] + +# ---- argument-SHAPE errors raise deterministically (before the tape) ---- +caught is 0 +try: + bad is net_recv of [sid] +catch e: + caught is 1 +assert of [caught == 1, "NET22 net_recv missing max_bytes raises"] + +caught2 is 0 +try: + bad2 is net_recv of [sid, 0, 50] +catch e: + caught2 is 1 +assert of [caught2 == 1, "NET23 net_recv max_bytes < 1 raises"] + +caught3 is 0 +try: + bad3 is net_listen of "nope" +catch e: + caught3 is 1 +assert of [caught3 == 1, "NET24 net_listen non-numeric port raises"] + +print of "All net tests passed"