Skip to content

Commit 187efc8

Browse files
rileyrclaude
andauthored
v0.8.2: docs cleanup + 9-PR bug-fix bundle (#21)
Docs cleanup, fixing post-2026-05-25 staleness around the narrowed two-channel rule (open-question #24, `47e03e8`): - AGENTS.md: rewrites the bullet that said "no fallible(E) on locus methods" and the matching diagnostics hint. Substrate-facing surfaces (lifecycle, mode, closure- assertion, bus-handler) reject fallible; user-declared `fn` members + free fns allow it. - agents/library-dev.md: same shape with a worked example showing a fallible user `fn` member inside a locus. - docs/src/concepts/error-handling.md: bridging-channels intro paragraph distinguishes the rejected surfaces from the allowed ones explicitly. - spec/projects.md: passing reference updated. - spec/stdlib.md: `std::io::udp` row's parenthetical drops the "multicast bus transport is a separate design under consideration" note (shipped this cycle via `udp://` substrate transport) and cross-refs the `std::bus` row. Workspace version bumps 0.8.1 → 0.8.2. What landed since v0.8.1: - #5 docs: document udp:// bus transport surface - #6 bus: spill-to-heap payloads + jumbo-aware UDP transport - #7 bus deserialize: bound-check length prefixes (closes priceview crash) - #8 bus udp: bind multicast receivers to the group address (fix crosstalk) - #14 std::crypto: add crc32(b: Bytes) -> Int (#12) - #15 std::io::tcp: add set_recv_timeout / set_send_timeout (#13) - #16 std::io::tcp::Stream: bus-routed I/O observability (#10, TCP part) - #17 std::http::Server: bus-routed observability (closes #10 modulo TLS) - #19 bus udp: array-of-pointers for remote entries (fix listen+connect SIGSEGV) - #20 release: drop macos-13 (Intel Mac) from the build matrix Three orthogonal udp:// bugs closed in this cycle (#7, #8, #19); release-workflow Intel-Mac hang resolved (#20); two new stdlib surfaces shipped (CRC32, tcp timeouts); bus observability extended to Stream + http::Server via log_subject. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bc47c75 commit 187efc8

7 files changed

Lines changed: 81 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,17 @@ Filter these reflexes before they cost you time.
146146
concatenates its args. F-strings `f"hello {name}"` interpolate.
147147
- **No `return` inside `birth` / `run` / `dissolve` bodies.**
148148
Factor short-circuit logic into helper free fns.
149-
- **No `fallible(E)` on locus methods.** Free fns and
150-
`@form(...)`-synthesized methods are the only fallible
151-
surfaces. Locus methods communicate failure structurally via
152-
the `` channel (closures + `on_failure`). Two-channel rule,
153-
locked.
149+
- **`fallible(E)` is rejected on substrate-facing surfaces:**
150+
lifecycle methods (`birth` / `run` / `dissolve`), mode bodies
151+
(`bulk` / `harmonic` / `resolution`), closure-assertion
152+
bodies, and bus-subscribed handlers. Those have no caller
153+
frame to address the error channel, so a `fallible(E)`
154+
declaration would describe a contract that can't be
155+
satisfied. User-declared `fn` members on a locus and free
156+
fns DO carry `fallible(E)` — they have a real caller. The
157+
narrowed two-channel rule (2026-05-25) keeps `` and `fallible`
158+
separate at the substrate boundary; everywhere else they
159+
compose. See `spec/semantics.md § fallible-on-locus`.
154160
- **No `panic(msg)` / `assert(cond)`.** Failure is structural,
155161
routed through closure-tests + `on_failure` (the `` channel)
156162
or value-level via `fallible(E)`.
@@ -197,8 +203,13 @@ surprises:
197203
they address bus channels only.
198204
- `self` outside a method body → you're in a free fn or top
199205
level; no enclosing Σ.
200-
- Lifecycle method declared `fallible(E)` → see "no fallible on
201-
locus methods" above. Convert to free fn.
206+
- Lifecycle / mode / closure-assertion / bus-handler method
207+
declared `fallible(E)` → the substrate orchestrates these,
208+
so the error channel has no caller to address. Drop the
209+
`fallible(E)` and route failure through `` (closure-test
210+
+ `on_failure`), OR factor the body into a user-declared
211+
`fn` member that the lifecycle method calls with `or` to
212+
bridge the channels.
202213
- "Error not addressed" on a `fallible` call → add `or raise` /
203214
`or default` / `or handler(err)`.
204215

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ members = [
1010
]
1111

1212
[workspace.package]
13-
version = "0.8.1"
13+
version = "0.8.2"
1414
edition = "2021"
1515
authors = ["the Hale authors"]
1616
license = "Apache-2.0"

agents/library-dev.md

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,47 @@ The spec is **not aspirational**. If it's in
128128
shipped behavior. If a feature has been removed, the spec
129129
entry must be removed too.
130130

131-
## Two-channel rule (locked design)
132-
133-
Locus methods cannot declare `fallible(E)`. The two surfaces
134-
that *can* are free fns and `@form(...)`-synthesized methods.
135-
This is permanent design, not a temporary limit — see
136-
`spec/design-rationale.md` F-numbered commitment on the
137-
two-channel rule.
138-
139-
If you need a stdlib operation to be fallible, add it as a
140-
free fn (`std::io::fs::read_to_string(path) -> fallible(String,
141-
IoError)`) or surface it through an `@form` whose synthesis
142-
produces fallible accessors.
131+
## Two-channel rule (narrowed 2026-05-25)
132+
133+
`fallible(E)` is rejected on **substrate-facing surfaces**:
134+
lifecycle methods (`birth` / `run` / `dissolve`), mode bodies
135+
(`bulk` / `harmonic` / `resolution`), closure-assertion
136+
bodies, and bus-subscribed handlers. The substrate
137+
orchestrates those — there's no caller frame to address
138+
the error channel, so a `fallible(E)` declaration would
139+
describe a contract that can't be satisfied. Surface failure
140+
there through `` (closure-test + `on_failure`).
141+
142+
`fallible(E)` IS allowed on user-declared `fn` members and
143+
on free fns — both have a real caller that can `or raise` /
144+
`or default` / `or handler(err)` the result. Library code
145+
that wants to expose a fallible operation as a method on a
146+
locus type can declare it as a user `fn` member:
147+
148+
```hale
149+
locus Reader {
150+
fn parse(b: Bytes) -> Message fallible(ParseError) {
151+
if bad { fail ParseError { msg: "bad header" }; }
152+
return Message { ... };
153+
}
154+
run() {
155+
let m = self.parse(b) or default_message();
156+
}
157+
}
158+
```
159+
160+
The earlier blanket rule ("locus methods can't be fallible
161+
at all") was narrowed because the friction signal across
162+
multiple libraries showed devs extracting free fns just to
163+
get a value-error channel back — losing `self` ergonomics
164+
and splitting closely-related code across two top-level
165+
decls. See `spec/semantics.md § fallible-on-locus` for the
166+
canonical statement and `notes/open-questions.md § #24` for
167+
the rationale + rejected alternatives.
168+
169+
`@form(...)`-synthesized accessors (`get` / `set` /
170+
`array_at`) remain fallible like before — that's
171+
orthogonal to the narrowing.
143172

144173
## Naming aliases for libraries
145174

docs/src/concepts/error-handling.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,15 @@ loci, so the value channel fits.
296296

297297
## Bridging the channels: structural failure from value-error context
298298

299-
The two-channel rule keeps locus methods off the value channel —
300-
but real systems regularly need to *cross from one to the other*.
301-
A locus method catches a value error in an `or` clause, decides
302-
the error is unrecoverable, and wants to immediately escalate
303-
into the structural channel so the parent's `on_failure` policy
304-
takes over.
299+
The two-channel rule (narrowed 2026-05-25) keeps **substrate-facing
300+
surfaces** — lifecycle / mode / closure-assertion / bus-handler
301+
bodies — off the value channel. User-declared `fn` member fns
302+
on a locus and free fns DO carry `fallible(E)` and live on the
303+
value channel like normal. Real systems regularly need to *cross
304+
from value to structural* — a method catches a value error in an
305+
`or` clause, decides the error is unrecoverable, and wants to
306+
immediately escalate into the structural channel so the parent's
307+
`on_failure` policy takes over.
305308

306309
Hale's primitive for this is **inline closure violation**: a
307310
locus declares a *named structural-failure type* as an

spec/projects.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,11 @@ import "<path>" as <alias>;
154154
The alias-required rule is the same forcing-function discipline
155155
v1.x-3 enforces for `: projection recognition` (no default
156156
sub-mode) and v1.x-FORM-2 enforces for the two-channel rule
157-
(locus methods can't declare `fallible(E)`): the user names the
158-
commitment at the surface so a downstream reader doesn't have
159-
to reconstruct the namespace from the path.
157+
(substrate-facing surfaces — lifecycle, mode, closure assertions,
158+
bus handlers — can't declare `fallible(E)`; user-declared `fn`
159+
members and free fns can): the user names the commitment at the
160+
surface so a downstream reader doesn't have to reconstruct the
161+
namespace from the path.
160162

161163
Imports appear at the top of a file, before any top-level
162164
declaration. Multi-file seeds may declare imports in any file;

spec/stdlib.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ surface without touching the compiler.
6969
| `std::io::file` | `File` locus (held-open fd with auto-dissolve close). `open(path, mode) -> File fallible(IoError)`; `read_line(f) -> String` (returns "" at EOF or error — pair with `at_eof`); `at_eof(f) -> Bool`; `write_bytes(f, b)`, `write_line(f, s)`, `seek(f, offset)` all `fallible(IoError)`. Mode strings `"r"` / `"w"` / `"a"` / `"r+"` / `"w+"`. Returned Strings live in the bus payload arena. | `lotus_file_*` C primitives + `runtime/stdlib/file.hl` |
7070
| `std::io::stdin` | `read_line() -> String`, `read_line_status() -> Int` (status `-1` = EOF/IO error; `0` = OK including empty-line) | POSIX `getline` + payload-arena copy |
7171
| `std::io::tcp` | `Listener` locus, `Stream` locus, `send`, `send_bytes`, `recv_bytes`, `recv_into(fd, buf: Bytes, max_bytes) -> Int` (caller-provided builder destination). Path-calls `listen_socket`, `connect`, `accept_one` are `fallible(IoError)`. `connect` accepts dotted-quad hosts directly and falls back to hostname resolution via `getaddrinfo(AF_INET)`. Send/recv timeouts (2026-05-27): `set_recv_timeout(fd, d: Duration) -> () fallible(IoError)` wraps `SO_RCVTIMEO`; `set_send_timeout(fd, d: Duration)` wraps `SO_SNDTIMEO`. `d = 0` disables (blocking default). After `set_recv_timeout(fd, 100ms)` a `recv_bytes` on a quiet socket returns ~100ms instead of blocking forever — unblocks recv loops that need periodic silence-detection / heartbeat / watchdog work. Shares the `sock_set_timeout_ns` helper with the udp siblings (P4, 2026-05-26). **Bus-routed I/O observability** (2026-05-27): `Stream` gains a `log_subject: String = ""` param and a `bus { publish "io.tcp.**" of type std::io::tcp::LogEvent; }` declaration. When `log_subject` is set, every send / recv / close on that Stream publishes a `LogEvent { phase, detail, bytes, fd }` on the configured subject. Empty `log_subject` (the default) gates the publish with a single `len(s) > 0` branch — zero hot-path cost. Users wire any subscriber locus they want (`subscribe "io.tcp.**" as on_evt of type std::io::tcp::LogEvent`) — stderr sink, structured log, metrics, ring buffer; the bus is the indirection so no `Logger` interface or per-Stream sink locus is needed. Closes the "I/O lib is silent by default with no hook" friction. | `lotus_tcp_*` C primitives |
72-
| `std::io::udp` | `bind(host, port) -> Int fallible(IoError)` (`host=""` → INADDR_ANY); `send(fd, host, port, msg)`, `recv(fd, max_bytes)`, `recv_into(fd, buf: Bytes, max_bytes)`, `close(fd)`. Multicast (2026-05-26 P1): `join_group(fd, group, iface) -> () fallible(IoError)` (iface=`""` → INADDR_ANY), `leave_group(fd, group, iface)`, `set_multicast_ttl(fd, ttl)` (0..255), `set_multicast_loop(fd, enabled: Bool)` (whether the sender receives its own packets), `set_multicast_iface(fd, addr)`. Transparent setsockopt pass-through (P2): `set_option_int(fd, level, name, value)`, `set_option_bool(fd, level, name, enabled)`, `get_option_int(fd, level, name) -> Int` — paired with `std::io::sockopt::<NAME>()` named constants below for the `level` / `name` args. Source-bearing recv + timeouts (2026-05-26 P4): `recv_with_source(fd, max_bytes) -> Bytes fallible(IoError)` populates a thread-local source cache; `last_source_host() -> String` / `last_source_port() -> Int` read the cache from the most-recent recv_with_source on the current thread (errno-style; read immediately after recv). `set_recv_timeout(fd, d: Duration) -> () fallible(IoError)` / `set_send_timeout(fd, d: Duration)` wrap `SO_RCVTIMEO` / `SO_SNDTIMEO` (they take a struct timeval so can't ride `set_option_int`); `d = 0` disables (blocking default). Datagram boundaries preserved. **NOT a bus transport** — UDP doesn't satisfy the bus's atomic-delivery contract (multicast bus transport is a separate design under consideration; see open questions). | `lotus_udp_*` C primitives |
72+
| `std::io::udp` | `bind(host, port) -> Int fallible(IoError)` (`host=""` → INADDR_ANY); `send(fd, host, port, msg)`, `recv(fd, max_bytes)`, `recv_into(fd, buf: Bytes, max_bytes)`, `close(fd)`. Multicast (2026-05-26 P1): `join_group(fd, group, iface) -> () fallible(IoError)` (iface=`""` → INADDR_ANY), `leave_group(fd, group, iface)`, `set_multicast_ttl(fd, ttl)` (0..255), `set_multicast_loop(fd, enabled: Bool)` (whether the sender receives its own packets), `set_multicast_iface(fd, addr)`. Transparent setsockopt pass-through (P2): `set_option_int(fd, level, name, value)`, `set_option_bool(fd, level, name, enabled)`, `get_option_int(fd, level, name) -> Int` — paired with `std::io::sockopt::<NAME>()` named constants below for the `level` / `name` args. Source-bearing recv + timeouts (2026-05-26 P4): `recv_with_source(fd, max_bytes) -> Bytes fallible(IoError)` populates a thread-local source cache; `last_source_host() -> String` / `last_source_port() -> Int` read the cache from the most-recent recv_with_source on the current thread (errno-style; read immediately after recv). `set_recv_timeout(fd, d: Duration) -> () fallible(IoError)` / `set_send_timeout(fd, d: Duration)` wrap `SO_RCVTIMEO` / `SO_SNDTIMEO` (they take a struct timeval so can't ride `set_option_int`); `d = 0` disables (blocking default). Datagram boundaries preserved. **`std::io::udp` is the raw-socket primitive, not a bus transport** — its `recv` / `send` calls don't carry the bus's typed-payload-dispatch contract. For UDP-as-bus see the `std::bus` row's `udp://host:port` substrate transport (shipped 2026-05-26): single URL scheme covers IPv4 unicast and multicast, dispatch goes through the same `LOTUS_BUS_CONFIG` route as `unix://`, lossy delivery (publisher-side "sendto returned" durability; subscribers best-effort). | `lotus_udp_*` C primitives |
7373
| `std::io::sockopt` | Named-constant getters returning the platform's numeric value for each setsockopt level / name. Use as the `level` / `name` args to `std::io::udp::set_option_*` / `get_option_int`. Each is a zero-arg fn (`std::io::sockopt::SO_RCVBUF()` etc.) so the value tracks the kernel headers; cross-platform without hardcoding. Shipped (2026-05-26): `SOL_SOCKET`, `IPPROTO_IP`, `IPPROTO_IPV6`, `IPPROTO_TCP`, `IPPROTO_UDP`, `SO_REUSEADDR`, `SO_REUSEPORT`, `SO_RCVBUF`, `SO_SNDBUF`, `SO_RCVTIMEO`, `SO_SNDTIMEO`, `SO_BROADCAST`, `SO_KEEPALIVE`, `SO_LINGER`, `SO_PRIORITY`, `SO_BINDTODEVICE`, `IP_TTL`, `IP_TOS`, `IP_MULTICAST_TTL`, `IP_MULTICAST_LOOP`, `IP_MULTICAST_IF`, `IP_ADD_MEMBERSHIP`, `IP_DROP_MEMBERSHIP`, `IP_PKTINFO`. PMTU surface added 2026-05-27: `IP_MTU_DISCOVER` + `IP_PMTUDISC_DONT` / `IP_PMTUDISC_WANT` / `IP_PMTUDISC_DO` / `IP_PMTUDISC_PROBE` (Linux-only; returns -1 on platforms missing the constant — caller can detect and skip). | `lotus_sockopt_<NAME>` C getters |
7474
| `std::io::tls` | Client-side TLS via system OpenSSL. `connect(host, port) -> Int fallible(IoError)` does the TCP connection + TLS 1.2+ handshake with SNI + system-trust-store cert verification. `send_bytes` / `recv_bytes` / `recv_into` / `close` over the handshaked connection. Process-global `SSL_CTX` runs with `SSL_MODE_RELEASE_BUFFERS` — OpenSSL releases its read/write buffers between records so long-running TLS clients don't accumulate ~32 KiB per idle connection. The `lotus_tls.c` TU compiles separately so helper tests linking `lotus_arena.c` directly don't drag in libssl/libcrypto. | `lotus_tls_*` in `runtime/lotus_tls.c` |
7575
| `std::http` | `Request` + `Response` types (`Response.headers: String` carries CRLF-joined user-supplied headers — no trailing CRLF — for Set-Cookie / CORS / custom headers); `parse_request`, `write_response`; case-insensitive symmetric `header(receiver, name)` lookup; `Handler` interface (`fn handle(req: Request) -> Response`); `Server` locus with `shutdown()` (cross-thread safe — see [§ Server.shutdown](#servershutdown--interruptible-accept-loop)) and optional `ready_signal: String` for piped oracles. **Bus-routed observability** (2026-05-27): `Server` gains a `log_subject: String = ""` param and a `bus { publish "io.http.**" of type std::io::tcp::LogEvent; }` declaration. When `log_subject` is set, listen-start / accept / listen-close events publish on the configured subject; empty (default) keeps the hot path at a single `len > 0` branch per event. Reuses the `std::io::tcp::LogEvent` type so one subscriber can observe both TCP and HTTP layers. | `runtime/stdlib/http.hl` |

0 commit comments

Comments
 (0)