ADR 0060 — Random bytes, Channel.tryRecv, C allocations, IoError.HostNotFound
- Status: Accepted by the owner, 2026-11-03 ("Lets go back to CeeSafe once again to implement the following request by CeeSafe.Web"), with the choices of §3 as recommended; §5 lists them for the owner to revisit. Asked for by CeeSafe.Web (
~/dev/CeeSafe.Web/docs/ceesafe-upstream-work.md, items U16, U13, U14 and U15), as one release. - Scope: lib/std, the hosted and core runtime, the built-in
IoError, the interpreter, the build's C headers.
1. Context
Four small gaps, each found while CeeSafe.Web designed a milestone:
- U16, random bytes (blocks the WebSocket client, Web ADR 0010 §3.6). lib/std gives a program no random bytes. The hosted runtime reads the system's source (
cee_random_fill,runtime/hosted/ceesafe_rt_io.c) only to seedHashMap's keys (ceesafe_random_key, ADR 0052). A WebSocket client needs 16 random bytes per connection (Sec-WebSocket-Key, RFC 6455 §4.1) and a masking key per frame "derived from a strong source of entropy" (§5.3); QUIC connection ids, session ids and CSRF tokens follow. - U13, a receive that does not wait (Web ADR 0008 §3.3–3.4).
Channel<T>(ADR 0032) hastrySend, but every receive waits.web_ssewants to drop the oldest event of a full queue, and to write every event already queued before one flush. - U14, C allocations (Web ADR 0002 §3.5, §6.1). OpenSSL can allocate through
CRYPTO_set_mem_functions. Throughceesafe_allocits allocations would be counted and fault-injected, so the allocation-failure sweep would reach OpenSSL's out-of-memory paths, but its global state (error strings, providers, the default context) lives until the process exits, and every test would report it as a leak. A package's C file has no declared header for the allocator either. - U15, a name that does not resolve (Web M5).
AsyncDns.resolvemapsEAI_NONAME,EAI_NODATAandEAI_ADDRFAMILYto-ENOENT(ADR 0048 §3.1), so an unknown host isIoError.NotFound, whose message is "No such file or directory".
2. Options
U16: where the bytes come from on each call.
| A. The system's source on every call (proposed) | B. A user-space ChaCha20 seeded from it | |
|---|---|---|
| Cost per call | macOS and the BSDs: arc4random_buf, a user-space ChaCha20 already; Linux: one getrandom system call (the vDSO's getrandom where glibc has it); Windows: BCryptGenRandom, a user-mode per-processor generator | a few nanoseconds |
| Risks | none of its own | a generator state to reseed after fork, to keep per thread or under a lock, and to get right: what the system already does |
A per-frame call costs about as much as the frame's own socket write at worst (§7 measures it). B would duplicate the system's generator and its fork handling; it can come later behind the same API if a measurement asks for it.
U16: fallible or not. getrandom can fail (ENOSYS before Linux 3.17, then /dev/urandom may be missing in a container), and CosmoOS userland has no source at all. HashMap can keep a fixed key, since its order is its only use of the bytes, but a masking key or a session token must never silently be predictable: fallible, Result<void, IoError>.
U13: how "closed" is told apart. A Result<T?, …> makes every caller match three cases where the uses above need two; isClosed() answers the rare question separately. tryRecv() -> T? and isClosed() -> bool.
U14: an allocator number or a separate API. The request names an allocator number; the core already routes every allocation by one (CEE_ALLOC_SYSTEM, CEE_ALLOC_FAILING). A number keeps one counter of attempts, so CEESAFE_FAIL_ALLOC_AT numbers CeeSafe's and C's allocations in one sequence, which is what the sweep needs.
U15: a new variant or a better message. A message for NotFound that fits both a file and a host cannot be glibc's wording, and a program could still not tell the two apart. A new variant, after Unsupported so every earlier variant keeps its tag (as ADR 0044 did).
3. Decision
U16.
// lib/std, hosted
public struct Random {
/// Fills `into` with bytes from the system's cryptographic random source.
public fn fill(into: SliceMut<u8>) -> Result<void, IoError>;
}- The runtime's
int64_t ceesafe_random_fill(uint8_t *out, uint64_t length), 0 or a negated errno:- Linux and other POSIX systems:
getrandom(out, n, 0), retried onEINTRand for short reads; onENOSYS, or where the C library has nogetrandom,/dev/urandom.getrandomwithout flags waits only until the kernel's pool is first initialised, early in boot. - macOS and the BSDs:
arc4random_buf, which cannot fail. - Windows:
BCryptGenRandom(NULL, …, BCRYPT_USE_SYSTEM_PREFERRED_RNG), in pieces of at mostULONG_MAXbytes; a failure isEIO. - CosmoOS userland:
EOPNOTSUPP(IoError.Unsupported).
- Linux and other POSIX systems:
- No effects (
@effects()): it does not allocate, and after boot it does not wait, so async code anddropblocks may call it, once per WebSocket frame. HashMap's seed reads through the same function; where it fails, the seed keeps its fixed key, as today.- The interpreter answers a fixed sequence (SplitMix64 from a fixed seed, continuing from call to call), so a run stays reproducible (§40.8). Its bytes are not secret; the guide says so.
- Hosted only: in freestanding code
Randomis not defined (N0200, as every lib/std type).
U13.
// lib/std, Channel<T>
public fn tryRecv(borrow this) -> T?; // the next buffered value, or null at once (empty, or closed and empty)
public fn isClosed(borrow this) -> bool; // whether close() has been called; values may still be buffered- Both have no effects, like
trySend. A drained channel isisClosed()thentryRecv() == null, in that order: no value is sent afterclose, so the order rules out a value sent between the two calls. - The runtime's
bool ceesafe_chan_closed(void *channel)reads the flag under the channel's lock.
U14.
#define CEE_ALLOC_FOREIGN 2u: an allocator number for C code. Its attempts are counted and fault-injected like every other (one sequence forCEESAFE_FAIL_ALLOC_AT); its live blocks are counted apart and are not a leak.- With it,
ceesafe_alloc,ceesafe_reallocandceesafe_freefollow C'smalloc,reallocandfreewhere C differs:ceesafe_free(NULL, …)does nothing, andceesafe_realloc(NULL, n, …)allocates. - A new header,
ceesafe/foreign.h, declares the three functions and the number. ceesafec writes it besideceesafe/rt.hin every build, whose include directory every C file of the build already has (-Ion the work directory, ADR 0047 §3.3), so a package's C files include it as#include "ceesafe/foreign.h".rt.hincludes it, so the declarations exist once. - The report (
CEESAFE_ALLOC_REPORT,ceesafec test) keeps its line,ceesafe alloc: attempts=<n> live=<m>, whereattemptsnow includes C's andlivedoes not; when C allocated at all, a second line follows:ceesafe alloc: foreign held=<k>, the blocks C still holds at exit, for information.
U15.
IoError.HostNotFound, variant 19, afterUnsupported; its message is "Host not found".- The resolver's "no such name" (
EAI_NONAME,EAI_NODATA,EAI_ADDRFAMILY; WindowsWSAHOST_NOT_FOUND,WSANO_DATA) and an answer without addresses areHostNotFound, through a runtime-only error number (CEE_ERROR_HOST_NOT_FOUND, 65536, which no system's errno uses) thatceesafe_io_kindmaps to it. - The interpreter's resolver model answers
HostNotFoundfor the reserved.invalidtop-level domain (RFC 6761 §6.4), andUnsupportedfor other names besideslocalhost, as before.
4. Consequences
- CeeSafe.Web's WebSocket client gets its keys and masks;
web_ssegetsDropOldestand batched flushes; OpenSSL's allocations join the sweep in test builds; a DNS failure says so. - A source change: a
matchoverIoErrorthat names every variant and has no_arm no longer covers it (N0256), as with ADR 0044 in 0.7.0. ceesafec's own sources have none. - New diagnostics: none.
5. Choices for the owner to revisit
Random.fillreads the system's source on every call, with no user-space generator (§2, §7's measurements).Random.fillis fallible,Result<void, IoError>; CosmoOS userland isUnsupported.- The interpreter's random bytes are a fixed, reproducible sequence.
Channel.tryRecv() -> T?with a separateisClosed().CEE_ALLOC_FOREIGN's live blocks are reported on their own line and never fail a test.IoError.HostNotFound, message "Host not found", also for a name with no addresses.
6. Implementation plan
- Runtime:
ceesafe_random_fill,ceesafe_chan_closed,CEE_ALLOC_FOREIGN,CEE_IO_HOST_NOT_FOUNDand the resolver's mapping;ceesafe/foreign.h;tools/gen_runtime.py. - The built-in
IoError(src/check/structs.cee) and its message (core runtime, interpreter). - lib/std:
Random,Channel.tryRecvandisClosed,HostServices.hostError;tools/gen_library.py. - The interpreter's models:
ceesafe_random_fill,ceesafe_chan_closed, the.invalidnames, error 65536. - Tests: run-pass for each (natively and in the interpreter), a check-fail for the freestanding name, and a contract-suite tool for the native random bytes and for a C file allocating through
CEE_ALLOC_FOREIGNunder the sweep. - Spec (type-system.md §26.2, §26.12, §39 channels, §28.6; tooling.md §4.1), the guide, ROADMAP §10.
7. Implementation notes (2026-11-03)
- The cost of
Random.fill, a 4-byte fill as the mean of 100 000 calls (tools/random_check.py): 0.04–0.05 µs on macOS (arc4random_buf), 0.57 µs on Debian 12 (Linux 6.1, glibc 2.36: agetrandomsystem call each time), and below the 15.6 ms tick of Windows' millisecond clock over the 100 000 calls, so under 0.16 µs, on Windows 11 ARM64. No user-space generator is needed (§2). rt.hincludes"foreign.h", its neighbour, not"ceesafe/foreign.h": the first form compiled only where the build's directory was on the include path, and C written by--emit cand compiled by hand has no such flag (found on Debian).- Package C files already had the runtime's headers:
-Ion the build directory reaches every C file, those compiled apart withinclude-dirstoo (src/host/toolchain.cee,pushCommonFlags). The new header gives C code a small contract instead of all ofrt.h. ceesafe_free(NULL, …)does nothing for every allocator, andceesafe_realloc(NULL, n, …)allocates for every one; generated code never does either (its frees are guarded by a capacity or a null check), so only C sees the difference.- The interpreter's sources cannot name
HostNotFound, since stage 0 (ceesafec 0.3.2, Decision 0009) does not have it:src/interpreter/io.ceerecognises the variant by its message, as it does ADR 0044's. - check-pass snapshots:
async-timeout.hirandeffects-of.hirdiffer in closure ids only (lib/std's new items move them), checked normalised and updated.