From 224eaae6f687baaa84f4f1606482f546a1802284 Mon Sep 17 00:00:00 2001 From: cs01 Date: Mon, 20 Apr 2026 09:10:16 -0700 Subject: [PATCH 1/4] add net module: tcp sockets via libuv (createconnection, socket.write/end/destroy, on connect/data/error/close events). prereq for pure-ts postgres driver. smoke test passes (connect-refused path). --- .github/workflows/ci.yml | 3 + c_bridges/net-bridge.c | 401 +++++++++++++++++++++++++++++++++ lib/net.ts | 225 ++++++++++++++++++ scripts/build-vendor.sh | 11 + src/compiler.ts | 13 +- src/native-compiler-lib.ts | 23 +- tests/fixtures/net/tcp-echo.ts | 31 +++ 7 files changed, 701 insertions(+), 6 deletions(-) create mode 100644 c_bridges/net-bridge.c create mode 100644 lib/net.ts create mode 100644 tests/fixtures/net/tcp-echo.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 303560f9..1dfb09d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,6 +307,7 @@ jobs: cp c_bridges/lws-bridge.o release/lib/ cp c_bridges/multipart-bridge.o release/lib/ cp c_bridges/regex-bridge.o release/lib/ + cp c_bridges/net-bridge.o release/lib/ cp vendor/rure/librure.a release/lib/ cp c_bridges/child-process-bridge.o release/lib/ cp c_bridges/child-process-spawn.o release/lib/ @@ -461,6 +462,7 @@ jobs: cp c_bridges/lws-bridge.o release/lib/ cp c_bridges/multipart-bridge.o release/lib/ cp c_bridges/regex-bridge.o release/lib/ + cp c_bridges/net-bridge.o release/lib/ cp vendor/rure/librure.a release/lib/ cp c_bridges/child-process-bridge.o release/lib/ cp c_bridges/child-process-spawn.o release/lib/ @@ -539,6 +541,7 @@ jobs: lws-bridge.o \ multipart-bridge.o \ regex-bridge.o \ + net-bridge.o \ librure.a \ child-process-bridge.o \ child-process-spawn.o \ diff --git a/c_bridges/net-bridge.c b/c_bridges/net-bridge.c new file mode 100644 index 00000000..61ef0ae2 --- /dev/null +++ b/c_bridges/net-bridge.c @@ -0,0 +1,401 @@ +// net-bridge.c — plain-TCP client sockets via libuv. +// +// Exposes a synchronous TCP client API to ChadScript user code. The bridge +// runs libuv's default loop in blocking (UV_RUN_ONCE) and non-blocking +// (UV_RUN_NOWAIT) modes as needed to drive connect/read/write/close. All +// async events are buffered inside per-socket state; TS-side listeners are +// invoked by ChadScript when it calls cs_net_poll_* to drain the event queue. +// +// No trampoline / function-pointer callbacks cross the FFI boundary — the +// bridge owns all uv_* callbacks, and TS polls for completed work. This +// keeps the FFI surface a flat set of C functions with scalar/string args, +// avoids entanglement with ChadScript closure codegen, and is enough for +// request/response protocols (Postgres wire, Redis RESP, plain HTTP). +// +// Event model: the bridge tracks four event kinds per socket — connect, +// data, error, close — and queues them on a per-socket FIFO linked list. +// TS drains them via cs_net_poll_event_kind + cs_net_poll_event_data + +// cs_net_poll_event_consume. TS dispatches to on(event, cb) listeners in +// user code. + +#include +#include +#include +#include +#include +#include +#include + +extern void *GC_malloc(size_t); +extern void *GC_malloc_atomic(size_t); + +#define NET_EVENT_CONNECT 1 +#define NET_EVENT_DATA 2 +#define NET_EVENT_ERROR 3 +#define NET_EVENT_CLOSE 4 + +typedef struct NetEvent { + int kind; + char *data; // payload (err message for error, bytes for data). NULL for connect/close. + size_t data_len; // byte count for data events + struct NetEvent *next; +} NetEvent; + +typedef struct { + uv_tcp_t handle; + uv_connect_t connect_req; + int connected; // 1 after successful connect, 0 before + int connect_failed; // 1 if connect errored + int closed; // 1 after uv_close completed + int close_requested; // 1 if end/destroy already initiated + int reading; // 1 if uv_read_start is active + + // Inbound byte buffer (growing). Separate from event queue so the caller + // can use a pull-style API (cs_net_rx_drain) without needing listeners. + char *rx_buf; + size_t rx_len; + size_t rx_cap; + + // Event queue (FIFO linked list) for push-style listener dispatch. + NetEvent *ev_head; + NetEvent *ev_tail; + + // Last error message, GC-allocated. + char *last_error; +} NetSocket; + +static void net_enqueue(NetSocket *s, int kind, const char *data, size_t data_len) { + NetEvent *ev = (NetEvent *)GC_malloc(sizeof(NetEvent)); + ev->kind = kind; + ev->data = NULL; + ev->data_len = 0; + ev->next = NULL; + if (data && data_len > 0) { + char *copy = (char *)GC_malloc_atomic(data_len + 1); + memcpy(copy, data, data_len); + copy[data_len] = '\0'; + ev->data = copy; + ev->data_len = data_len; + } + if (s->ev_tail) { + s->ev_tail->next = ev; + s->ev_tail = ev; + } else { + s->ev_head = ev; + s->ev_tail = ev; + } +} + +static void net_alloc_cb(uv_handle_t *h, size_t suggested, uv_buf_t *buf) { + (void)h; + buf->base = (char *)malloc(suggested); + buf->len = suggested; +} + +static void net_close_cb(uv_handle_t *h) { + NetSocket *s = (NetSocket *)uv_handle_get_data(h); + if (!s) return; + s->closed = 1; + net_enqueue(s, NET_EVENT_CLOSE, NULL, 0); +} + +static void net_read_cb(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf) { + NetSocket *s = (NetSocket *)uv_handle_get_data((uv_handle_t *)stream); + if (!s) { + if (buf->base) free(buf->base); + return; + } + if (nread > 0) { + size_t needed = s->rx_len + (size_t)nread; + if (needed > s->rx_cap) { + size_t newcap = s->rx_cap > 0 ? s->rx_cap : 4096; + while (newcap < needed) newcap *= 2; + char *nb = (char *)malloc(newcap); + if (s->rx_len > 0) memcpy(nb, s->rx_buf, s->rx_len); + free(s->rx_buf); + s->rx_buf = nb; + s->rx_cap = newcap; + } + memcpy(s->rx_buf + s->rx_len, buf->base, (size_t)nread); + s->rx_len += (size_t)nread; + net_enqueue(s, NET_EVENT_DATA, buf->base, (size_t)nread); + } else if (nread < 0) { + if (nread == UV_EOF) { + if (!s->close_requested && !s->closed) { + s->close_requested = 1; + uv_read_stop(stream); + uv_close((uv_handle_t *)&s->handle, net_close_cb); + } + } else { + const char *msg = uv_strerror((int)nread); + net_enqueue(s, NET_EVENT_ERROR, msg, strlen(msg)); + if (!s->close_requested && !s->closed) { + s->close_requested = 1; + uv_read_stop(stream); + uv_close((uv_handle_t *)&s->handle, net_close_cb); + } + } + } + if (buf->base) free(buf->base); +} + +static void net_connect_cb(uv_connect_t *req, int status) { + NetSocket *s = (NetSocket *)req->data; + if (status < 0) { + const char *msg = uv_strerror(status); + size_t n = strlen(msg); + char *copy = (char *)GC_malloc_atomic(n + 1); + memcpy(copy, msg, n + 1); + s->last_error = copy; + s->connect_failed = 1; + net_enqueue(s, NET_EVENT_ERROR, msg, n); + return; + } + s->connected = 1; + net_enqueue(s, NET_EVENT_CONNECT, NULL, 0); + uv_read_start((uv_stream_t *)&s->handle, net_alloc_cb, net_read_cb); + s->reading = 1; +} + +// Resolve host:port via libuv's sync getaddrinfo. Fast-paths dotted-quad +// literals to avoid the DNS round-trip. Returns 0 on success; libuv errno +// (negative) on failure. +static int net_resolve(const char *host, int port, struct sockaddr_in *out) { + if (uv_ip4_addr(host, port, out) == 0) return 0; + + uv_getaddrinfo_t resolver; + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + int r = uv_getaddrinfo(uv_default_loop(), &resolver, NULL, host, NULL, &hints); + if (r < 0) return r; + if (!resolver.addrinfo) return -1; + + struct sockaddr_in *addr = (struct sockaddr_in *)resolver.addrinfo->ai_addr; + out->sin_family = AF_INET; + out->sin_addr = addr->sin_addr; + out->sin_port = htons((uint16_t)port); + uv_freeaddrinfo(resolver.addrinfo); + return 0; +} + +// ---- write request plumbing ---- + +typedef struct { + uv_write_t req; + char *owned; // malloc'd copy of caller bytes; freed in net_write_done +} net_write_req_t; + +static void net_write_done(uv_write_t *req, int status) { + (void)status; + net_write_req_t *wr = (net_write_req_t *)req; + free(wr->owned); + free(wr); +} + +// ---- public API ---- + +// Open a TCP connection to host:port. Blocks until the handshake succeeds +// or fails (drives uv_run in UV_RUN_ONCE mode until a terminal event). +// Returns an opaque NetSocket* on success, NULL on failure. After a NULL +// return the caller gets no further use of the socket. +void *cs_net_connect(const char *host, double port) { + if (!host) return NULL; + int port_i = (int)port; + + NetSocket *s = (NetSocket *)GC_malloc(sizeof(NetSocket)); + memset(s, 0, sizeof(*s)); + + uv_loop_t *loop = uv_default_loop(); + uv_tcp_init(loop, &s->handle); + uv_tcp_nodelay(&s->handle, 1); + uv_handle_set_data((uv_handle_t *)&s->handle, s); + s->connect_req.data = s; + + struct sockaddr_in addr; + int rr = net_resolve(host, port_i, &addr); + if (rr < 0) { + const char *msg = uv_strerror(rr); + size_t n = strlen(msg); + char *copy = (char *)GC_malloc_atomic(n + 1); + memcpy(copy, msg, n + 1); + s->last_error = copy; + uv_close((uv_handle_t *)&s->handle, NULL); + uv_run(loop, UV_RUN_NOWAIT); + return NULL; + } + + int cr = uv_tcp_connect(&s->connect_req, &s->handle, + (const struct sockaddr *)&addr, net_connect_cb); + if (cr < 0) { + const char *msg = uv_strerror(cr); + size_t n = strlen(msg); + char *copy = (char *)GC_malloc_atomic(n + 1); + memcpy(copy, msg, n + 1); + s->last_error = copy; + uv_close((uv_handle_t *)&s->handle, NULL); + uv_run(loop, UV_RUN_NOWAIT); + return NULL; + } + + while (!s->connected && !s->connect_failed) { + if (uv_run(loop, UV_RUN_ONCE) == 0) break; + } + + if (s->connect_failed) { + if (!s->close_requested && !s->closed) { + s->close_requested = 1; + uv_close((uv_handle_t *)&s->handle, net_close_cb); + uv_run(loop, UV_RUN_NOWAIT); + } + return NULL; + } + return (void *)s; +} + +// Return the last error message recorded on the socket, or "" if no error. +const char *cs_net_last_error(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || !s->last_error) return ""; + return s->last_error; +} + +// Write bytes asynchronously. The caller's bytes are copied internally so +// the original buffer can be freed/reused immediately. Drives the loop once +// in NOWAIT mode so the write has a chance to flush. Returns 1 on success +// (the write was queued), 0 if the socket is closed/invalid. +double cs_net_write(void *sock, const char *data, double len) { + NetSocket *s = (NetSocket *)sock; + if (!s || s->closed || s->close_requested || !s->connected) return 0.0; + + size_t n = (size_t)len; + char *copy = (char *)malloc(n); + memcpy(copy, data, n); + + net_write_req_t *wr = (net_write_req_t *)malloc(sizeof(net_write_req_t)); + wr->owned = copy; + uv_buf_t buf = uv_buf_init(copy, (unsigned int)n); + + int r = uv_write(&wr->req, (uv_stream_t *)&s->handle, &buf, 1, net_write_done); + if (r < 0) { + free(copy); + free(wr); + return 0.0; + } + uv_run(uv_default_loop(), UV_RUN_NOWAIT); + return 1.0; +} + +// Non-blocking poll: tick the loop once, then return the current queued +// event count for this socket. +double cs_net_poll(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s) return 0.0; + uv_run(uv_default_loop(), UV_RUN_NOWAIT); + double n = 0.0; + for (NetEvent *e = s->ev_head; e; e = e->next) n += 1.0; + return n; +} + +// Block up to timeout_ms running the loop until at least one event is queued +// on this socket (or the timeout expires). Returns the queued event count. +// timeout_ms <= 0 behaves like cs_net_poll. +double cs_net_wait(void *sock, double timeout_ms) { + NetSocket *s = (NetSocket *)sock; + if (!s) return 0.0; + uv_loop_t *loop = uv_default_loop(); + if (timeout_ms <= 0.0) { + uv_run(loop, UV_RUN_NOWAIT); + } else { + uint64_t deadline = uv_hrtime() + (uint64_t)(timeout_ms * 1e6); + while (!s->ev_head) { + if (uv_hrtime() >= deadline) break; + if (uv_run(loop, UV_RUN_ONCE) == 0) break; + } + } + double n = 0.0; + for (NetEvent *e = s->ev_head; e; e = e->next) n += 1.0; + return n; +} + +// Peek the head event kind (NET_EVENT_*). 0 if queue empty. +double cs_net_poll_event_kind(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || !s->ev_head) return 0.0; + return (double)s->ev_head->kind; +} + +// Peek the head event's payload as a char*. Empty string for connect/close +// events or empty queue. +const char *cs_net_poll_event_data(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || !s->ev_head || !s->ev_head->data) return ""; + return s->ev_head->data; +} + +// Peek the head event's payload length (0 if none). +double cs_net_poll_event_len(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || !s->ev_head) return 0.0; + return (double)s->ev_head->data_len; +} + +// Pop the head event off the queue. Safe on empty queue. +void cs_net_poll_event_consume(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || !s->ev_head) return; + NetEvent *head = s->ev_head; + s->ev_head = head->next; + if (!s->ev_head) s->ev_tail = NULL; +} + +// Half-close: send FIN. Read callback will still fire until peer closes. +// Idempotent. +void cs_net_end(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || s->closed || s->close_requested) return; + s->close_requested = 1; + if (s->reading) { + uv_read_stop((uv_stream_t *)&s->handle); + s->reading = 0; + } + uv_close((uv_handle_t *)&s->handle, net_close_cb); + uv_run(uv_default_loop(), UV_RUN_NOWAIT); +} + +// Hard-close. For plain TCP clients this is equivalent to end() — libuv's +// uv_close already cancels pending writes and fires the close callback. +void cs_net_destroy(void *sock) { + cs_net_end(sock); +} + +// 1 iff the socket is connected and not closing; 0 otherwise. +double cs_net_is_open(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s) return 0.0; + if (s->closed || s->close_requested || s->connect_failed) return 0.0; + return s->connected ? 1.0 : 0.0; +} + +// Drain and return all currently-buffered inbound bytes as a GC-allocated, +// NUL-terminated string. Clears the rx buffer. Use cs_net_rx_drain_len +// beforehand to learn the exact byte count (the payload may contain +// embedded NULs). +const char *cs_net_rx_drain(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s || s->rx_len == 0) return ""; + size_t n = s->rx_len; + char *out = (char *)GC_malloc_atomic(n + 1); + memcpy(out, s->rx_buf, n); + out[n] = '\0'; + s->rx_len = 0; + return out; +} + +double cs_net_rx_drain_len(void *sock) { + NetSocket *s = (NetSocket *)sock; + if (!s) return 0.0; + return (double)s->rx_len; +} diff --git a/lib/net.ts b/lib/net.ts new file mode 100644 index 00000000..ede1730f --- /dev/null +++ b/lib/net.ts @@ -0,0 +1,225 @@ +// net.ts — plain TCP client sockets via libuv (c_bridges/net-bridge.c). +// +// API shape mirrors Node's `node:net` closely enough to be familiar: +// +// import { createConnection } from "chadscript/net"; +// const sock = createConnection("127.0.0.1", 5432); +// sock.on("connect", (_: string): void => { sock.write("hello"); }); +// sock.on("data", (chunk: string): void => { console.log("got", chunk); }); +// sock.on("close", (_: string): void => { console.log("bye"); }); +// sock.poll(); // drive events and dispatch +// sock.wait(1000); // same, blocking up to 1s +// +// ## Known limits +// +// - One listener per event kind. ChadScript's current codegen does not +// support arrays of function values (`Array<(x) => void>` errors with +// "function-pointer arrays not yet supported"), so each `on()` call +// overwrites the previous listener for that event kind. Multi-listener +// support will land as soon as the runtime grows a function-pointer +// array type. The four event kinds (connect/data/error/close) cover the +// Postgres-driver use-case without multi-listener needs. +// - No server-side API (createServer/listen). Client-only today — lws-bridge +// already covers HTTP/WS server needs; TCP servers will arrive alongside +// a custom-protocol server use-case (Redis-style, Postgres-proxy, etc.). +// - No TLS. starttls() is a separate bridge (mbedTLS / OpenSSL BIO layer) — +// tracked as a followup. removeDataListener() is provided specifically so +// a future TLS upgrade can detach the plaintext data handler and hand the +// socket to the TLS layer. +// - Callbacks are dispatched from poll()/wait() — not interrupt-driven. User +// code must periodically call sock.poll() or sock.wait(timeoutMs) from its +// own event loop. This is a deliberate tradeoff: keeps the FFI surface +// pure scalar/string (no trampoline-handle plumbing), at the cost of the +// caller driving the loop. +// - `write()` takes a string (bytes). A `Uint8Array` overload can be added +// once chad's codegen exposes a byte-aware string constructor for the FFI +// (Uint8Array.fromRawBytes has the shape; the reverse direction needs a +// helper). +// - Errors are surfaced as the "error" event with a string payload. No +// structured error object yet — matches how lws-bridge surfaces failures. +// - The underlying uv_default_loop() is shared with HTTP server, timers, and +// spawn(). Using net inside an httpServe handler will tick the same loop — +// callers must not call sock.wait() from within a handler (would recurse +// into the loop). Use non-blocking poll() from handlers instead. + +declare function cs_net_connect(host: string, port: number): string; +declare function cs_net_last_error(sock: string): string; +declare function cs_net_write(sock: string, data: string, len: number): number; +declare function cs_net_poll(sock: string): number; +declare function cs_net_wait(sock: string, timeoutMs: number): number; +declare function cs_net_poll_event_kind(sock: string): number; +declare function cs_net_poll_event_data(sock: string): string; +declare function cs_net_poll_event_len(sock: string): number; +declare function cs_net_poll_event_consume(sock: string): void; +declare function cs_net_end(sock: string): void; +declare function cs_net_destroy(sock: string): void; +declare function cs_net_is_open(sock: string): number; +declare function cs_net_rx_drain(sock: string): string; +declare function cs_net_rx_drain_len(sock: string): number; + +const NET_EVENT_CONNECT: number = 1; +const NET_EVENT_DATA: number = 2; +const NET_EVENT_ERROR: number = 3; +const NET_EVENT_CLOSE: number = 4; + +// Shared no-op listener — used to initialize Socket's callback slots so the +// field type (function) has a valid non-null default. A single module-level +// function reference avoids the `store i8* __lambda_N` IR codegen bug that +// fires when arrow-function literals are assigned in a class constructor. +function _netNoopCb(_d: string): void {} + +export class Socket { + private _handle: string; + private _dead: number; + + // Single-slot listeners per event kind (see "Known limits" above — chad's + // codegen can't express `Array` today). Each slot is either a live + // function or a sentinel (assigned on construction) that no-ops. The + // sentinel pattern keeps the invocation path branch-free at the call + // site. + private _hasConnect: number; + private _connectCb: (data: string) => void; + private _hasData: number; + private _dataCb: (data: string) => void; + private _hasError: number; + private _errorCb: (data: string) => void; + private _hasClose: number; + private _closeCb: (data: string) => void; + + constructor(handle: string) { + this._handle = handle; + this._dead = 0; + this._hasConnect = 0; + this._hasData = 0; + this._hasError = 0; + this._hasClose = 0; + // Callback fields are left as class-default (null function pointer) + // until a real on() call binds them — guarded by the _has* flags. + // Explicit sentinel assignments here hit a current chad codegen bug + // that emits unbound __lambda_N identifiers in the IR. + this._connectCb = _netNoopCb; + this._dataCb = _netNoopCb; + this._errorCb = _netNoopCb; + this._closeCb = _netNoopCb; + } + + // Register a listener. The connect listener fires exactly once after the + // handshake completes. data / error may fire multiple times; close fires + // at most once per lifetime of the socket. The payload for connect and + // close is the empty string. Calling on() for an event kind that already + // has a listener REPLACES it — see "Known limits" for why. + on(event: string, cb: (data: string) => void): void { + if (event === "connect") { + this._connectCb = cb; + this._hasConnect = 1; + } else if (event === "data") { + this._dataCb = cb; + this._hasData = 1; + } else if (event === "error") { + this._errorCb = cb; + this._hasError = 1; + } else if (event === "close") { + this._closeCb = cb; + this._hasClose = 1; + } + } + + // Detach the data listener. Used by a TLS upgrade layer (future) to swap + // the plaintext handler for a TLS record demultiplexer. The signature + // parameter is accepted for API symmetry with node:net but ignored — + // there's only one slot. + removeDataListener(_cb: (data: string) => void): void { + this._hasData = 0; + this._dataCb = _netNoopCb; + } + + // Send bytes. Returns true if the write was queued, false if the socket + // is already closed or in error. The bytes are copied synchronously, so + // the caller can mutate/reuse `data` immediately on return. + write(data: string): boolean { + if (this._dead === 1) return false; + const r = cs_net_write(this._handle, data, data.length); + return r > 0; + } + + // Half-close: send FIN. Peer-side reads will EOF, our reads keep working + // until the peer closes. Idempotent. + end(): void { + if (this._dead === 1) return; + cs_net_end(this._handle); + } + + // Hard-close. Pending writes are cancelled by libuv. Idempotent. + destroy(): void { + if (this._dead === 1) return; + this._dead = 1; + cs_net_destroy(this._handle); + } + + isOpen(): boolean { + return cs_net_is_open(this._handle) > 0; + } + + // Tick the libuv loop once in non-blocking mode, then dispatch any queued + // events to listeners. Returns the number of events dispatched. Safe to + // call from inside handlers for other subsystems. + poll(): number { + cs_net_poll(this._handle); + return this._drain(); + } + + // Block for up to timeoutMs ticking the loop until at least one event + // shows up for this socket (or the timeout expires). Then dispatch + // queued events. Returns the number of events dispatched. + wait(timeoutMs: number): number { + cs_net_wait(this._handle, timeoutMs); + return this._drain(); + } + + // Pull-style read helper: drain the internal rx byte buffer and return + // everything that arrived since the last call. The "data" event fires in + // parallel with this — callers can pick either style, not both. + read(): string { + return cs_net_rx_drain(this._handle); + } + + readLen(): number { + return cs_net_rx_drain_len(this._handle); + } + + // Drain the bridge's event queue, dispatching each event to its listener. + // Returns the number of events dispatched. + private _drain(): number { + let dispatched: number = 0; + let kind: number = cs_net_poll_event_kind(this._handle); + while (kind !== 0) { + const payload: string = cs_net_poll_event_data(this._handle); + cs_net_poll_event_consume(this._handle); + dispatched = dispatched + 1; + if (kind === NET_EVENT_CONNECT) { + if (this._hasConnect === 1) this._connectCb(""); + } else if (kind === NET_EVENT_DATA) { + if (this._hasData === 1) this._dataCb(payload); + } else if (kind === NET_EVENT_ERROR) { + if (this._hasError === 1) this._errorCb(payload); + } else if (kind === NET_EVENT_CLOSE) { + this._dead = 1; + if (this._hasClose === 1) this._closeCb(""); + } + kind = cs_net_poll_event_kind(this._handle); + } + return dispatched; + } +} + +// Open a TCP connection. Blocks until the handshake completes or fails. +// Always returns a Socket — the caller checks sock.isOpen() (or waits for +// the "error" event) to see whether the connect succeeded. This shape was +// chosen over throw-on-failure because the bridge returns an opaque "NULL" +// pointer on failure, and ChadScript currently compares those to empty +// string unreliably across FFI boundaries. Callers who prefer exception +// semantics can wrap their own check: `if (!sock.isOpen()) throw ...`. +export function createConnection(host: string, port: number): Socket { + const handle: string = cs_net_connect(host, port); + return new Socket(handle); +} diff --git a/scripts/build-vendor.sh b/scripts/build-vendor.sh index 16156cd8..78d6bab4 100755 --- a/scripts/build-vendor.sh +++ b/scripts/build-vendor.sh @@ -480,6 +480,17 @@ else echo "==> child-process-spawn already built, skipping" fi +# --- net-bridge (TCP client sockets via libuv) --- +NET_BRIDGE_SRC="$C_BRIDGES_DIR/net-bridge.c" +NET_BRIDGE_OBJ="$C_BRIDGES_DIR/net-bridge.o" +if [ ! -f "$NET_BRIDGE_OBJ" ] || [ "$NET_BRIDGE_SRC" -nt "$NET_BRIDGE_OBJ" ]; then + echo "==> Building net-bridge..." + cc -c -O2 -fPIC -I"$VENDOR_DIR/libuv/include" "$NET_BRIDGE_SRC" -o "$NET_BRIDGE_OBJ" + echo " -> $NET_BRIDGE_OBJ" +else + echo "==> net-bridge already built, skipping" +fi + # --- trampoline-bridge (C-ABI closure slot table) --- TRAMP_SRC="$C_BRIDGES_DIR/trampoline-bridge.c" TRAMP_OBJ="$C_BRIDGES_DIR/trampoline-bridge.o" diff --git a/src/compiler.ts b/src/compiler.ts index 2fbb42da..6ed3bea3 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -395,7 +395,8 @@ export function compile( generator.usesPromises || generator.usesCurl || generator.usesUvHrtime || - generator.usesHttpServer + generator.usesHttpServer || + generator.declaredExternFunctions.some((n: string) => n.startsWith("cs_net_")) ) { linkLibs += ` -L${uvPath} -luv`; } @@ -409,10 +410,13 @@ export function compile( linkLibs += " -lsqlite3"; } let usesPostgres = false; + let usesNet = false; for (let i = 0; i < generator.declaredExternFunctions.length; i++) { - if (generator.declaredExternFunctions[i].startsWith("cs_pg_")) { + const fn = generator.declaredExternFunctions[i]; + if (fn.startsWith("cs_pg_")) { usesPostgres = true; - break; + } else if (fn.startsWith("cs_net_")) { + usesNet = true; } } if (usesPostgres) { @@ -485,6 +489,7 @@ export function compile( const cpSpawnObj = generator.getUsesSpawn() ? `${bridgePath}/child-process-spawn.o` : ""; const curlBridgeObj = generator.usesCurl ? `${bridgePath}/curl-bridge.o` : ""; const pgBridgeObj = usesPostgres ? `${bridgePath}/pg-bridge.o` : ""; + const netBridgeObj = usesNet ? `${bridgePath}/net-bridge.o` : ""; const compressBridgeObj = generator.usesCompression ? `${bridgePath}/compress-bridge.o` : ""; const yamlBridgeObj = generator.usesYaml ? `${bridgePath}/yaml-bridge.o` : ""; let extraObjs = ""; @@ -650,7 +655,7 @@ export function compile( const userObjs = extraLinkObjs.length > 0 ? " " + extraLinkObjs.join(" ") : ""; const userPaths = extraLinkPaths.map((p) => ` -L${p}`).join(""); const userLibs = extraLinkLibs.map((l) => ` -l${l}`).join(""); - const linkCmd = `${linker} ${objFile} ${lwsBridgeObj} ${regexBridgeObj} ${cpBridgeObj} ${osBridgeObj} ${strlenCacheObj} ${timeBridgeObj} ${base64BridgeObj} ${urlBridgeObj} ${uriBridgeObj} ${dotenvBridgeObj} ${watchBridgeObj} ${arenaBridgeObj} ${trampBridgeObj} ${cpSpawnObj} ${curlBridgeObj} ${pgBridgeObj} ${compressBridgeObj} ${yamlBridgeObj} ${stringOpsBridgeObj}${extraObjs}${userObjs} -o ${outputFile}${noPie}${debugFlag}${stripFlag}${staticFlag}${crossTarget}${crossLinker}${suppressLdWarnings}${sanitizeFlags} ${linkLibs}${userPaths}${userLibs}`; + const linkCmd = `${linker} ${objFile} ${lwsBridgeObj} ${regexBridgeObj} ${cpBridgeObj} ${osBridgeObj} ${strlenCacheObj} ${timeBridgeObj} ${base64BridgeObj} ${urlBridgeObj} ${uriBridgeObj} ${dotenvBridgeObj} ${watchBridgeObj} ${arenaBridgeObj} ${trampBridgeObj} ${cpSpawnObj} ${curlBridgeObj} ${pgBridgeObj} ${netBridgeObj} ${compressBridgeObj} ${yamlBridgeObj} ${stringOpsBridgeObj}${extraObjs}${userObjs} -o ${outputFile}${noPie}${debugFlag}${stripFlag}${staticFlag}${crossTarget}${crossLinker}${suppressLdWarnings}${sanitizeFlags} ${linkLibs}${userPaths}${userLibs}`; logger.info(` ${linkCmd}`); const linkStdio = logger.getLevel() >= LogLevel_Verbose ? "inherit" : "pipe"; execSync(linkCmd, { stdio: linkStdio }); diff --git a/src/native-compiler-lib.ts b/src/native-compiler-lib.ts index 49333ce8..52ed5a81 100644 --- a/src/native-compiler-lib.ts +++ b/src/native-compiler-lib.ts @@ -667,15 +667,31 @@ export function compileNative(inputFile: string, outputFile: string): void { linkLibs = "-lsqlite3 " + linkLibs; } let usesPostgres: boolean = false; + let usesNet: boolean = false; for (let i = 0; i < generator.declaredExternFunctions.length; i++) { - if (generator.declaredExternFunctions[i].startsWith("cs_pg_")) { + const fn = generator.declaredExternFunctions[i]; + if (fn.startsWith("cs_pg_")) { usesPostgres = true; - break; + } else if (fn.startsWith("cs_net_")) { + usesNet = true; } } if (usesPostgres) { linkLibs = "-lpq " + linkLibs; } + // net-bridge needs libuv. Add it if not already pulled in by a prior use. + if ( + usesNet && + !( + generator.getUsesTimers() || + generator.getUsesPromises() || + generator.getUsesCurl() || + generator.getUsesUvHrtime() || + generator.getUsesHttpServer() + ) + ) { + linkLibs = "-L" + uvDir + " -luv " + linkLibs; + } if (generator.getUsesHttpServer()) { linkLibs = "-lz -lzstd " + linkLibs; } @@ -718,6 +734,7 @@ export function compileNative(inputFile: string, outputFile: string): void { const cpSpawnObj = generator.getUsesSpawn() ? effectiveBridgePath + "/child-process-spawn.o" : ""; const curlBridgeObj = generator.getUsesCurl() ? effectiveBridgePath + "/curl-bridge.o" : ""; const pgBridgeObj = usesPostgres ? effectiveBridgePath + "/pg-bridge.o" : ""; + const netBridgeObj = usesNet ? effectiveBridgePath + "/net-bridge.o" : ""; const compressBridgeObj = generator.getUsesCompression() ? effectiveBridgePath + "/compress-bridge.o" : ""; @@ -785,6 +802,8 @@ export function compileNative(inputFile: string, outputFile: string): void { " " + pgBridgeObj + " " + + netBridgeObj + + " " + compressBridgeObj + " " + yamlBridgeObj + diff --git a/tests/fixtures/net/tcp-echo.ts b/tests/fixtures/net/tcp-echo.ts new file mode 100644 index 00000000..f1c87f87 --- /dev/null +++ b/tests/fixtures/net/tcp-echo.ts @@ -0,0 +1,31 @@ +// TCP client smoke test: exercises the net module's connect/error/close +// event path by attempting a connection to a port we know is closed. +// +// The connect-refusal path is deterministic on every OS/CI host and +// exercises the full bridge: uv_tcp_connect -> net_connect_cb(status<0) +// -> error event -> uv_close -> close event. Also verifies the Socket +// class wraps the bridge handle correctly and isOpen() reports the +// post-failure state. + +import { createConnection } from "chadscript/net"; + +function run(): void { + // Port 59999 is outside the registered-ports range and virtually never + // bound on dev/CI hosts. Pure IPv4 literal — keeps DNS out of the path. + const sock = createConnection("127.0.0.1", 59999); + + // Pump the loop briefly so the bridge can surface the connect-refused + // error (uv_tcp_connect's status < 0 path) and the subsequent close. + sock.poll(); + sock.wait(500); + + if (sock.isOpen()) { + console.log("FAIL: socket reports open against closed port"); + return; + } + + sock.destroy(); + console.log("TEST_PASSED"); +} + +run(); From ffe4e4e1ae32f565f0690d54caa5a7fcc6f2290f Mon Sep 17 00:00:00 2001 From: cs01 Date: Mon, 20 Apr 2026 09:47:34 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(net):=20register=20lib/net.ts=20with=20?= =?UTF-8?q?the=20native=20chad=20stdlib=20loader=20=E2=80=94=20ci=20was=20?= =?UTF-8?q?failing=20because=20chad=20build=20couldn't=20resolve=20the=20c?= =?UTF-8?q?hadscript/net=20import=20since=20registerStdlib=20call=20was=20?= =?UTF-8?q?missing.=20one-line=20fix=20in=20src/chad-native.ts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chad-native.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/chad-native.ts b/src/chad-native.ts index 59ea9941..df6333b3 100644 --- a/src/chad-native.ts +++ b/src/chad-native.ts @@ -23,6 +23,7 @@ registerStdlib("events.ts", ChadScript.embedFile("../lib/events.ts")); registerStdlib("glob.ts", ChadScript.embedFile("../lib/glob.ts")); registerStdlib("compress.ts", ChadScript.embedFile("../lib/compress.ts")); registerStdlib("postgres.ts", ChadScript.embedFile("../lib/postgres.ts")); +registerStdlib("net.ts", ChadScript.embedFile("../lib/net.ts")); const skillContent = ChadScript.embedFile("../lib/skill.md"); import { ArgumentParser } from "chadscript/argparse"; From 73f319833ed77302125acf992de76452a2405403 Mon Sep 17 00:00:00 2001 From: cs01 Date: Mon, 20 Apr 2026 09:49:25 -0700 Subject: [PATCH 3/4] =?UTF-8?q?add=20declare=20module=20'chadscript/net'?= =?UTF-8?q?=20to=20chadscript.d.ts=20=E2=80=94=20typedefs=20for=20socket?= =?UTF-8?q?=20class=20+=20createConnection=20so=20user=20code=20+=20ide=20?= =?UTF-8?q?gets=20typechecked=20(was=20missing=20from=20the=20initial=20ne?= =?UTF-8?q?t-module=20commit,=20paired=20with=20the=20registerStdlib=20fix?= =?UTF-8?q?).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chadscript.d.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/chadscript.d.ts b/chadscript.d.ts index 5b736525..589527a1 100644 --- a/chadscript.d.ts +++ b/chadscript.d.ts @@ -393,6 +393,57 @@ declare module "chadscript/argparse" { } } +declare module "chadscript/net" { + // Plain TCP client sockets via libuv. Prerequisite for protocol + // drivers (e.g. pure-TS Postgres). Event-driven; use on() + poll()/wait(), + // or the pull-style read() helper — not both for the same event kind. + export class Socket { + // Register a listener. Replaces any previous listener for the same + // event (single-slot per kind). Payload is the empty string for + // 'connect' and 'close'; 'data' delivers the chunk; 'error' delivers + // the libuv error message. + on(event: "connect" | "data" | "error" | "close", cb: (data: string) => void): void; + + // Detach the 'data' listener. Used by a future TLS upgrade layer to + // swap the plaintext handler for a TLS record demultiplexer. + removeDataListener(cb: (data: string) => void): void; + + // Send bytes. Returns true if queued, false if the socket is closed + // or in error. Bytes are copied synchronously. + write(data: string): boolean; + + // Half-close (FIN). Peer-side reads EOF; our reads keep working + // until the peer closes. Idempotent. + end(): void; + + // Hard-close. Pending writes are cancelled. Idempotent. + destroy(): void; + + // True while the connection is healthy. Flips to false after + // 'error' or 'close'. + isOpen(): boolean; + + // Tick the libuv loop once (non-blocking) and dispatch queued + // events. Returns number of events dispatched. + poll(): number; + + // Block for up to timeoutMs ticking the loop until at least one + // event shows up (or timeout). Then dispatch. Returns number of + // events dispatched. + wait(timeoutMs: number): number; + + // Pull-style read: drain the rx byte buffer. Parallel to the + // 'data' event — callers pick either style, not both. + read(): string; + readLen(): number; + } + + // Open a TCP connection. Returns immediately; handshake completes + // asynchronously. Always returns a Socket — check sock.isOpen() (or + // wait for the 'error' event) to see whether the connect succeeded. + export function createConnection(host: string, port: number): Socket; +} + declare module "chadscript/http" { export function getHeader(headersRaw: string, name: string): string; export function parseQueryString(qs: string): Map; From 9f8521f25a770927e1500e1e9e0c7ac2e8244a74 Mon Sep 17 00:00:00 2001 From: cs01 Date: Mon, 20 Apr 2026 15:19:20 -0700 Subject: [PATCH 4/4] fix(net): return non-null netsocket on connect failure so bridge null-guards aren't defeated by ffi null-to-empty-string conversion. poll/wait/isopen now see a real handle with connect_failed=closed=1 and behave as dead-socket instead of dereferencing the empty-string constant --- c_bridges/net-bridge.c | 43 +++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/c_bridges/net-bridge.c b/c_bridges/net-bridge.c index 61ef0ae2..06e5a125 100644 --- a/c_bridges/net-bridge.c +++ b/c_bridges/net-bridge.c @@ -199,15 +199,24 @@ static void net_write_done(uv_write_t *req, int status) { // Open a TCP connection to host:port. Blocks until the handshake succeeds // or fails (drives uv_run in UV_RUN_ONCE mode until a terminal event). -// Returns an opaque NetSocket* on success, NULL on failure. After a NULL -// return the caller gets no further use of the socket. +// Always returns a NetSocket* — on failure the returned socket has +// connect_failed=1 and closed=1 set, so cs_net_is_open/poll/etc behave +// like a dead socket. Returning a valid pointer on failure (instead of +// NULL) matters because the ChadScript FFI converts NULL char* returns +// into empty-string pointers, which defeat `if (!s)` guards in every +// other function in this bridge. last_error holds the uv_strerror msg. void *cs_net_connect(const char *host, double port) { - if (!host) return NULL; - int port_i = (int)port; - NetSocket *s = (NetSocket *)GC_malloc(sizeof(NetSocket)); memset(s, 0, sizeof(*s)); + if (!host) { + s->connect_failed = 1; + s->closed = 1; + s->last_error = (char *)"invalid host"; + return (void *)s; + } + int port_i = (int)port; + uv_loop_t *loop = uv_default_loop(); uv_tcp_init(loop, &s->handle); uv_tcp_nodelay(&s->handle, 1); @@ -222,9 +231,12 @@ void *cs_net_connect(const char *host, double port) { char *copy = (char *)GC_malloc_atomic(n + 1); memcpy(copy, msg, n + 1); s->last_error = copy; - uv_close((uv_handle_t *)&s->handle, NULL); - uv_run(loop, UV_RUN_NOWAIT); - return NULL; + s->connect_failed = 1; + uv_close((uv_handle_t *)&s->handle, net_close_cb); + while (!s->closed) { + if (uv_run(loop, UV_RUN_ONCE) == 0) break; + } + return (void *)s; } int cr = uv_tcp_connect(&s->connect_req, &s->handle, @@ -235,9 +247,12 @@ void *cs_net_connect(const char *host, double port) { char *copy = (char *)GC_malloc_atomic(n + 1); memcpy(copy, msg, n + 1); s->last_error = copy; - uv_close((uv_handle_t *)&s->handle, NULL); - uv_run(loop, UV_RUN_NOWAIT); - return NULL; + s->connect_failed = 1; + uv_close((uv_handle_t *)&s->handle, net_close_cb); + while (!s->closed) { + if (uv_run(loop, UV_RUN_ONCE) == 0) break; + } + return (void *)s; } while (!s->connected && !s->connect_failed) { @@ -248,9 +263,11 @@ void *cs_net_connect(const char *host, double port) { if (!s->close_requested && !s->closed) { s->close_requested = 1; uv_close((uv_handle_t *)&s->handle, net_close_cb); - uv_run(loop, UV_RUN_NOWAIT); + while (!s->closed) { + if (uv_run(loop, UV_RUN_ONCE) == 0) break; + } } - return NULL; + return (void *)s; } return (void *)s; }