network: Unix domain sockets #119

Closed
opened 2026-10-05 21:07:22 +00:00 by orion · 8 comments
Collaborator

Context

Local Postgres and Redis sockets, Docker's API. Listed among what std is missing for the first-party packages
(torb/postgres lists "Unix sockets" under "Not built"). Today they go over TCP to localhost.

Source

  • FIRST-PARTY.md gap 21
  • docs/design/NETWORK.md section 10: "A new one - Unix domain sockets ... calls one of the checks there, or refuses
    while a sandbox is open: the grant fails closed, never open."

Design (decided by the loop)

This is an epic of two steps plus one later slice. The change needs new natives, and the seed that builds the compiler
compiles std/network (compiler/src/debugger/adapter.trb imports it): the two-commit rule of CLAUDE.md applies, and
the second commit cannot bootstrap until the published seed knows the natives.

  • Package: std/network, a new file src/unix.trb, exported from src/lib.trb. Stream sockets only
    (SOCK_STREAM); datagram Unix sockets, abstract names, peer credentials and descriptor passing stay later additions.
  • API (step 2): the shape of TCP's types, methods and no settings object, a path as a String (so std/network
    gains no dependency on std/path):
    • public shared type UnixListener with Close: static fn listen(path: String, backlog: Int = 128): Result<UnixListener, NetworkError>, fn path(): String, fn accept(): Task<Result<UnixStream, NetworkError>>.
      Closing removes the socket file it bound.
    • public shared type UnixStream with Close: static fn connect(path: String): Task<Result<UnixStream, NetworkError>>, fn receive(maximum: Int = 65536): Task<Result<Bytes?, NetworkError>>, fn send(bytes: Bytes): Task<Result<Void, NetworkError>>, fn shutdown(): Result<Void, NetworkError>, fn source(chunk: Int = 65536): UnixSource, fn sink(): UnixSink; UnixSource and UnixSink are Source<Bytes, NetworkError> and
      Sink<Bytes, NetworkError> like TcpSource and TcpSink (end() is a half close).
    • Errors are the existing NetworkError (a new kind is never a breaking change); no new error type.
    • Why separate types and not a shared Stream trait: no generic transport abstraction exists yet (FIRST-PARTY.md gap
      17 is its own item); torb/postgres and torb/redis choose their transport behind their own private type, and
      two small mirrors are cheaper to undo than a premature trait.
  • Natives (step 1): networkListenUnix(path, backlog) and networkConnectUnix(path); accept, receive, send,
    shutdown and close are the existing natives on the new handles.
  • Sandbox: both natives refuse while a sandbox is open (fail closed, step 1). Path-pattern grants
    (network connect: "unix:/run/app.sock") are a later slice, filed only when a sandboxed script needs a Unix socket.
  • Platforms: Linux in the forge CI; macOS/BSD (kqueue) and Windows 10 1803+ (AF_UNIX over IOCP) written, as
    UDP was.

Steps

  • 1. Runtime natives, the sandbox refusal and their manifest entries (touches the seed; one commit feat(runtime): Unix domain socket natives): #428
  • 2. std/network UnixListener, UnixStream, UnixSource, UnixSink, tests in std/network/tests over a socket
    file in a temp directory (VM and native), docs (docs/standard-library/network.md, NETWORK.md section 4 and
    slice table, the generated skills/), FIRST-PARTY.md gap 21 marked done and the "Unix sockets" mention of
    torb/postgres left to that package. Commit feat(network): UnixListener and UnixStream. Filed as #546
    (step 1 landed, the published seed knows the natives). Landed as d4157f4153 (PR #548).
  • 3. (Later, not part of this epic's acceptance; filed as its own issue only when a sandboxed script needs it) a unix: pattern for the network grant in std/sandbox and
    runtime/sandbox.c.

Acceptance criteria (of the epic)

  • Step 1 landed
  • Step 2 landed: a listener bound to a path in a temp directory accepts a UnixStream.connect to it, bytes go both
    ways, shutdown() gives the peer the end of the stream, connecting to an unbound path fails with
    isConnectionRefused() or the unreachable kind, listening on an existing path fails with isAddressInUse(),
    the socket file is gone after the listener closed; natively and in the VM
  • FIRST-PARTY.md gap 21 marked done

Dependencies

None known; the steps depend on each other as listed. #120 (socket options) is implemented in parallel and edits the
same generated natives files; that is a rebase note for whichever lands second, not a dependency.

Moved from the planning lists of the docs; the design record stays the specification.

## Context Local Postgres and Redis sockets, Docker's API. Listed among what `std` is missing for the first-party packages (`torb/postgres` lists "Unix sockets" under "Not built"). Today they go over TCP to `localhost`. ## Source - [FIRST-PARTY.md gap 21](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/FIRST-PARTY.md#L281) - `docs/design/NETWORK.md` section 10: "A new one - Unix domain sockets ... calls one of the checks there, or refuses while a sandbox is open: the grant fails closed, never open." ## Design (decided by the loop) This is an epic of two steps plus one later slice. The change needs new natives, and the seed that builds the compiler compiles `std/network` (`compiler/src/debugger/adapter.trb` imports it): the two-commit rule of CLAUDE.md applies, and the second commit cannot bootstrap until the published seed knows the natives. - **Package:** `std/network`, a new file `src/unix.trb`, exported from `src/lib.trb`. Stream sockets only (`SOCK_STREAM`); datagram Unix sockets, abstract names, peer credentials and descriptor passing stay later additions. - **API (step 2):** the shape of TCP's types, methods and no settings object, a path as a `String` (so `std/network` gains no dependency on `std/path`): - `public shared type UnixListener with Close`: `static fn listen(path: String, backlog: Int = 128): Result<UnixListener, NetworkError>`, `fn path(): String`, `fn accept(): Task<Result<UnixStream, NetworkError>>`. Closing removes the socket file it bound. - `public shared type UnixStream with Close`: `static fn connect(path: String): Task<Result<UnixStream, NetworkError>>`, `fn receive(maximum: Int = 65536): Task<Result<Bytes?, NetworkError>>`, `fn send(bytes: Bytes): Task<Result<Void, NetworkError>>`, `fn shutdown(): Result<Void, NetworkError>`, `fn source(chunk: Int = 65536): UnixSource`, `fn sink(): UnixSink`; `UnixSource` and `UnixSink` are `Source<Bytes, NetworkError>` and `Sink<Bytes, NetworkError>` like `TcpSource` and `TcpSink` (`end()` is a half close). - Errors are the existing `NetworkError` (a new kind is never a breaking change); no new error type. - Why separate types and not a shared `Stream` trait: no generic transport abstraction exists yet (FIRST-PARTY.md gap 17 is its own item); `torb/postgres` and `torb/redis` choose their transport behind their own private type, and two small mirrors are cheaper to undo than a premature trait. - **Natives (step 1):** `networkListenUnix(path, backlog)` and `networkConnectUnix(path)`; accept, receive, send, shutdown and close are the existing natives on the new handles. - **Sandbox:** both natives refuse while a sandbox is open (fail closed, step 1). Path-pattern grants (`network connect: "unix:/run/app.sock"`) are a later slice, filed only when a sandboxed script needs a Unix socket. - **Platforms:** Linux in the forge CI; macOS/BSD (kqueue) and Windows 10 1803+ (`AF_UNIX` over IOCP) written, as UDP was. ## Steps - [x] 1. Runtime natives, the sandbox refusal and their manifest entries (touches the seed; one commit `feat(runtime): Unix domain socket natives`): #428 - [x] 2. `std/network` `UnixListener`, `UnixStream`, `UnixSource`, `UnixSink`, tests in `std/network/tests` over a socket file in a temp directory (VM and native), docs (`docs/standard-library/network.md`, NETWORK.md section 4 and slice table, the generated `skills/`), FIRST-PARTY.md gap 21 marked done and the "Unix sockets" mention of `torb/postgres` left to that package. Commit `feat(network): UnixListener and UnixStream`. Filed as #546 (step 1 landed, the published seed knows the natives). Landed as `d4157f4153` (PR #548). - [ ] 3. (Later, not part of this epic's acceptance; filed as its own issue only when a sandboxed script needs it) a `unix:` pattern for the `network` grant in `std/sandbox` and `runtime/sandbox.c`. ## Acceptance criteria (of the epic) - [x] Step 1 landed - [x] Step 2 landed: a listener bound to a path in a temp directory accepts a `UnixStream.connect` to it, bytes go both ways, `shutdown()` gives the peer the end of the stream, connecting to an unbound path fails with `isConnectionRefused()` or the unreachable kind, listening on an existing path fails with `isAddressInUse()`, the socket file is gone after the listener closed; natively and in the VM - [x] FIRST-PARTY.md gap 21 marked done ## Dependencies None known; the steps depend on each other as listed. #120 (socket options) is implemented in parallel and edits the same generated natives files; that is a rebase note for whichever lands second, not a dependency. _Moved from the planning lists of the docs; the design record stays the specification._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), source FIRST-PARTY.md gap 21, not a duplicate. No dependencies. next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), source FIRST-PARTY.md gap 21, not a duplicate. No dependencies. next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261023 until 2026-10-23T15:16Z

[loop:torbscript] claim refine by session:loop-torbscript-20261023 until 2026-10-23T15:16Z
Author
Collaborator

[loop:torbscript] decided by the loop: #119 is an epic, not one PR, and stays without ready. Why: it needs two new natives in runtime/io.c (listen on and connect to a path; accept, receive, send and close are the existing natives), per-system code in posix_io.c and iocp.c (sockaddr_un, a third AcceptEx/ConnectEx slot), a sandbox check, and then the std/network types with tests and docs. The natives touch the seed (CLAUDE.md "Seed and breaking changes"), so the two-commit rule splits the work: the second commit cannot bootstrap until the published seed knows the natives.

Design recorded in the body: new file std/network/src/unix.trb; UnixListener and UnixStream (plus UnixSource and UnixSink) mirror the TCP types, with methods and no settings object; the path is a String so std/network takes no dependency on std/path; stream sockets only; no shared stream trait, because gap 17 owns the transport question and two small mirrors are cheap to undo; the listener's close removes its socket file; errors are the existing NetworkError. Sandbox: both natives refuse while any sandbox is open (NETWORK.md section 10: "the grant fails closed, never open"); a unix: grant pattern is a later slice, filed only when a sandboxed script needs it. Names follow TcpListener/TcpStream and the "methods over free functions" convention (#146).

Filed the first step as #428 (runtime: Unix domain socket natives, touches the seed: yes, first commit of the rule, labels loop and ready; no open duplicate found by keyword search for unix, socket, domain socket, AF_UNIX, docker). Linked in the epic checklist; step 2 is filed once step 1 has landed and the published seed knows the natives.

Note, non-blocking: #120 step 1 (#426, socket option natives) is implemented in parallel and edits the same files (runtime/io.c, torb_network.h, natives.trb, the NATIVES.md table, the generated torb_natives.h and machine_natives.c). Whichever lands second rebases and regenerates the two generated files. Not a dependency.

refine: read FIRST-PARTY.md gap 21 and section 8/10, NETWORK.md sections 4, 10 and 11, NATIVES.md network table, CLAUDE.md, CONTRIBUTING; claim released with this comment. Worker usage: about 90k tokens (sonnet).
next: waiting (epic waits on its steps)

[loop:torbscript] decided by the loop: #119 is an epic, not one PR, and stays without `ready`. Why: it needs two new natives in `runtime/io.c` (listen on and connect to a path; accept, receive, send and close are the existing natives), per-system code in `posix_io.c` and `iocp.c` (`sockaddr_un`, a third `AcceptEx`/`ConnectEx` slot), a sandbox check, and then the `std/network` types with tests and docs. The natives touch the seed (CLAUDE.md "Seed and breaking changes"), so the two-commit rule splits the work: the second commit cannot bootstrap until the published seed knows the natives. Design recorded in the body: new file `std/network/src/unix.trb`; `UnixListener` and `UnixStream` (plus `UnixSource` and `UnixSink`) mirror the TCP types, with methods and no settings object; the path is a `String` so `std/network` takes no dependency on `std/path`; stream sockets only; no shared stream trait, because gap 17 owns the transport question and two small mirrors are cheap to undo; the listener's close removes its socket file; errors are the existing `NetworkError`. Sandbox: both natives refuse while any sandbox is open (NETWORK.md section 10: "the grant fails closed, never open"); a `unix:` grant pattern is a later slice, filed only when a sandboxed script needs it. Names follow `TcpListener`/`TcpStream` and the "methods over free functions" convention (#146). Filed the first step as #428 (`runtime: Unix domain socket natives`, touches the seed: yes, first commit of the rule, labels `loop` and `ready`; no open duplicate found by keyword search for unix, socket, domain socket, AF_UNIX, docker). Linked in the epic checklist; step 2 is filed once step 1 has landed and the published seed knows the natives. Note, non-blocking: #120 step 1 (#426, socket option natives) is implemented in parallel and edits the same files (`runtime/io.c`, `torb_network.h`, `natives.trb`, the NATIVES.md table, the generated `torb_natives.h` and `machine_natives.c`). Whichever lands second rebases and regenerates the two generated files. Not a dependency. refine: read FIRST-PARTY.md gap 21 and section 8/10, NETWORK.md sections 4, 10 and 11, NATIVES.md network table, CLAUDE.md, CONTRIBUTING; claim released with this comment. Worker usage: about 90k tokens (sonnet). next: waiting (epic waits on its steps)
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261028 until 2026-10-28T16:22:28Z

[loop:torbscript] claim refine by session:loop-torbscript-20261028 until 2026-10-28T16:22:28Z
Author
Collaborator

[loop:torbscript] decided by the loop: next step of #119 is step 2, filed as #546 (std/network UnixListener, UnixStream, UnixSource, UnixSink; labels loop and ready; linked in the checklist, step 1 ticked). Why now: step 1 (#428, PR #430, 5dd2b9a4) landed on 2026-10-23; the natives are in the published seeds (c4a8443532aa of 2026-10-24 and every later one, e.g. 142293fd98fc, are built from commits after 5dd2b9a4), so the second commit of the two-commit rule can bootstrap in CI. Seed: no further seed change and no new native in #546 (a pure std/ commit); the seed refresh stays with the merge gate and Torben. Size: one PR, one commit feat(network): UnixListener and UnixStream, ~250 lines of std/ mirroring TcpListener/TcpStream without options, address and acceptor, one test file over a socket file made with File.createTemporaryDirectory, three doc edits and regenerated skills/. Two small decisions beyond the recorded design: no acceptor() for Unix listeners (a later addition when a server needs every core), and the existing natives the Unix types need are made public native fn in tcp.trb like networkClose (internal, not exported by the package). Dependencies: none open; #473 (step 2 of #120, PR #477) edits the same files, a rebase note for whichever lands second. Duplicate check: keyword search of open issues for unix and socket found no other issue than the epic. Step 3 (unix: grant pattern) stays unfiled until a sandboxed script needs it.

[loop:torbscript] decided by the loop: next step of #119 is step 2, filed as #546 (`std/network` `UnixListener`, `UnixStream`, `UnixSource`, `UnixSink`; labels `loop` and `ready`; linked in the checklist, step 1 ticked). Why now: step 1 (#428, PR #430, `5dd2b9a4`) landed on 2026-10-23; the natives are in the published seeds (`c4a8443532aa` of 2026-10-24 and every later one, e.g. `142293fd98fc`, are built from commits after `5dd2b9a4`), so the second commit of the two-commit rule can bootstrap in CI. Seed: no further seed change and no new native in #546 (a pure `std/` commit); the seed refresh stays with the merge gate and Torben. Size: one PR, one commit `feat(network): UnixListener and UnixStream`, ~250 lines of `std/` mirroring `TcpListener`/`TcpStream` without options, address and acceptor, one test file over a socket file made with `File.createTemporaryDirectory`, three doc edits and regenerated `skills/`. Two small decisions beyond the recorded design: no `acceptor()` for Unix listeners (a later addition when a server needs every core), and the existing natives the Unix types need are made `public native fn` in `tcp.trb` like `networkClose` (internal, not exported by the package). Dependencies: none open; #473 (step 2 of #120, PR #477) edits the same files, a rebase note for whichever lands second. Duplicate check: keyword search of open issues for unix and socket found no other issue than the epic. Step 3 (`unix:` grant pattern) stays unfiled until a sandboxed script needs it.
Author
Collaborator

[loop:torbscript] refine: verified against main (step 1 landed, the published seed knows the natives), filed step 2 as #546 (ready) and linked it in the checklist; the epic stays open without ready, next: waiting (epic waits on #546)

[loop:torbscript] refine: verified against `main` (step 1 landed, the published seed knows the natives), filed step 2 as #546 (ready) and linked it in the checklist; the epic stays open without `ready`, next: waiting (epic waits on #546)
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261029 until 2026-10-29T01:13:11Z

[loop:torbscript] claim refine by session:loop-torbscript-20261029 until 2026-10-29T01:13:11Z
Author
Collaborator

[loop:torbscript] decided by the loop: closing the epic. Both steps landed on main: step 1 (#428, 5dd2b9a4, natives and sandbox refusal) and step 2 (#546, PR #548, d4157f4153 feat(network): UnixListener and UnixStream, followed by two docs fixes b1cda15cac and 38d3edd4ab). FIRST-PARTY.md gap 21 is marked done on main. main CI is green on the head 38d3edd4ab (gates, tier A, tier B, bootstrap on linux-x64, pdisc, commit messages all success). All three epic acceptance criteria are ticked in the body. Step 3 (a unix: pattern for the network grant) was never part of the acceptance criteria and is deliberately unfiled; it becomes its own loop issue when a sandboxed script needs a Unix socket. Landed commit: d4157f4153

[loop:torbscript] refine: verified against main that all steps (1 and 2) landed, ticked the checklist and criteria in the epic body, closed #119 per LOOP.md (work landed, main CI green, commit link above); no new step issue needed. Claim released. Worker usage: about 45k tokens (sonnet), next: done (closed)

[loop:torbscript] decided by the loop: closing the epic. Both steps landed on `main`: step 1 (#428, `5dd2b9a4`, natives and sandbox refusal) and step 2 (#546, PR #548, `d4157f4153` `feat(network): UnixListener and UnixStream`, followed by two docs fixes `b1cda15cac` and `38d3edd4ab`). FIRST-PARTY.md gap 21 is marked done on `main`. `main` CI is green on the head `38d3edd4ab` (gates, tier A, tier B, bootstrap on linux-x64, pdisc, commit messages all success). All three epic acceptance criteria are ticked in the body. Step 3 (a `unix:` pattern for the `network` grant) was never part of the acceptance criteria and is deliberately unfiled; it becomes its own `loop` issue when a sandboxed script needs a Unix socket. Landed commit: https://git.torb.dev/torbscript/language/commit/d4157f4153 [loop:torbscript] refine: verified against `main` that all steps (1 and 2) landed, ticked the checklist and criteria in the epic body, closed #119 per LOOP.md (work landed, `main` CI green, commit link above); no new step issue needed. Claim released. Worker usage: about 45k tokens (sonnet), next: done (closed)
orion closed this issue 2026-10-28 23:13:58 +00:00
Sign in to join this conversation.
No labels
loop
ready
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#119
No description provided.