From 81dd0177f8a3e5ee406882d1b17ef9aace043f19 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 24 Jun 2026 12:55:03 +0200 Subject: [PATCH 1/3] =?UTF-8?q?test(journal):=20fix=20log=5Fjournal=20read?= =?UTF-8?q?iness=20race=20=E2=80=94=20probe=20a=20real=20IPC=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log_journal restarts a `-l` server three times and, after each spawn, waited for readiness with a bare `echo > /dev/tcp/PORT` connect before `.ipc.open`. A bare TCP connect only proves listen(2) succeeded, which is reached before the server is accepting + completing the IPC handshake. On the slow macOS-debug (ASan) runner that gap let the probe report "ready" while the very next `(set h .ipc.open ...)` raced the not-yet-serving server and failed with `io` (intermittent CI failure at log_journal.rfl:106). The 5s handshake timeout rules out an accept-latency timeout; the `io` is a refused/reset connect — the listener was not truly serving when the proxy said it was. Make the readiness signal match what `.ipc.open` actually needs: each probe now COMPLETES an IPC handshake (send the 2-byte version frame `\003\000`, read a response byte) and only then declares the server ready. `read -t2` bounds each attempt without the non-portable `timeout(1)` (absent on macOS bash 3.2). Verified: full suite 3497/3499 (0 failed) and log_journal 20/20 on Linux. --- test/rfl/system/log_journal.rfl | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/rfl/system/log_journal.rfl b/test/rfl/system/log_journal.rfl index 1db428c6..bca8730f 100644 --- a/test/rfl/system/log_journal.rfl +++ b/test/rfl/system/log_journal.rfl @@ -10,6 +10,15 @@ ;; 19999 — `make test` is sequential so two server processes cannot ;; coexist on the same port anyway). ;; +;; Readiness probe: each restart waits for the server by COMPLETING AN IPC +;; HANDSHAKE (send the 2-byte version frame, read a response byte), not by a +;; bare TCP connect. A bare `echo > /dev/tcp` only proves listen(2) succeeded, +;; which is reached before the server is actually accepting+handshaking; on the +;; slow macOS-debug runner that gap let the probe report "ready" while the very +;; next (set h .ipc.open) raced and failed with `io`. Polling the real +;; handshake makes the readiness signal match what .ipc.open needs. `read -t2` +;; bounds each attempt without the non-portable `timeout(1)` (absent on macOS). +;; ;; Base path: /tmp/rftest_journal — anything starting with that prefix ;; is fair game for cleanup; we wipe it before AND after the run. ;; @@ -30,7 +39,7 @@ ;; ── phase 1: spawn server with -l, send mutations, kill ───────────── (.sys.exec "./rayforce -l /tmp/rftest_journal -p 19990 /dev/null 2>&1 &") -(.sys.exec "for i in $(seq 30); do bash -c '(echo > /dev/tcp/127.0.0.1/19990) 2>/dev/null' && exit 0; sleep 0.1; done; exit 1") -- 0 +(.sys.exec "for i in $(seq 50); do bash -c 'exec 3<>/dev/tcp/127.0.0.1/19990 2>/dev/null && printf \"\\003\\000\" >&3 && read -t2 -n1 -u3 _' 2>/dev/null && exit 0; sleep 0.1; done; exit 1") -- 0 (set h (.ipc.open "127.0.0.1:19990")) (>= h 0) -- true @@ -60,7 +69,7 @@ ;; ── phase 2: restart, assert replay restored a/b/c/d ──────────────── (.sys.exec "./rayforce -l /tmp/rftest_journal -p 19990 /dev/null 2>&1 &") -(.sys.exec "for i in $(seq 30); do bash -c '(echo > /dev/tcp/127.0.0.1/19990) 2>/dev/null' && exit 0; sleep 0.1; done; exit 1") -- 0 +(.sys.exec "for i in $(seq 50); do bash -c 'exec 3<>/dev/tcp/127.0.0.1/19990 2>/dev/null && printf \"\\003\\000\" >&3 && read -t2 -n1 -u3 _' 2>/dev/null && exit 0; sleep 0.1; done; exit 1") -- 0 (set h2 (.ipc.open "127.0.0.1:19990")) (>= h2 0) -- true @@ -101,7 +110,7 @@ ;; ── phase 3: restart, assert state survived via .qdb (not .log) ───── (.sys.exec "./rayforce -l /tmp/rftest_journal -p 19990 /dev/null 2>&1 &") -(.sys.exec "for i in $(seq 30); do bash -c '(echo > /dev/tcp/127.0.0.1/19990) 2>/dev/null' && exit 0; sleep 0.1; done; exit 1") -- 0 +(.sys.exec "for i in $(seq 50); do bash -c 'exec 3<>/dev/tcp/127.0.0.1/19990 2>/dev/null && printf \"\\003\\000\" >&3 && read -t2 -n1 -u3 _' 2>/dev/null && exit 0; sleep 0.1; done; exit 1") -- 0 (set h3 (.ipc.open "127.0.0.1:19990")) (.ipc.send h3 "a") -- 100 From b35ee185cf23629f7a791b1ddbe22e70acadbcfd Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 24 Jun 2026 14:19:29 +0200 Subject: [PATCH 2/3] refactor(lang): move filesystem builtins to a dedicated .fs.* namespace .os.* conflated process-environment (getenv/setenv) with filesystem metadata (size/list). Split the filesystem primitives out under .fs.* via a hard rename so .os.* is env-only and there is a clean home for future filesystem additions. .os.size -> .fs.size .os.list -> .fs.list Updates registrations, impl fn names + error strings, the reserved- namespace dual-binding test, and all doc surfaces (new fs.md, trimmed os.md, namespace index, restricted-mode list, mkdocs nav). --- docs/docs/namespaces/fs.md | 45 ++++++++++++++++++++++++++ docs/docs/namespaces/index.md | 5 +-- docs/docs/namespaces/os.md | 38 +++------------------- mkdocs.yml | 3 +- src/lang/eval.c | 11 ++++--- src/lang/internal.h | 6 ++-- src/ops/system.c | 22 ++++++------- test/rfl/system/db_sym_resolution.rfl | 18 +++++------ test/rfl/system/os_fs.rfl | 38 +++++++++++----------- test/rfl/system/reserved_namespace.rfl | 9 ++++-- test/rfl/system/system_branch_cov.rfl | 36 ++++++++++----------- 11 files changed, 128 insertions(+), 103 deletions(-) create mode 100644 docs/docs/namespaces/fs.md diff --git a/docs/docs/namespaces/fs.md b/docs/docs/namespaces/fs.md new file mode 100644 index 00000000..eac09377 --- /dev/null +++ b/docs/docs/namespaces/fs.md @@ -0,0 +1,45 @@ +# `.fs.*` — filesystem + +Minimal filesystem-metadata primitives. The namespace is deliberately small: two builtins cover the common needs (file size, directory listing) and the predicate cases (exists / is-file / is-dir) are reached by `try`-wrapping `.fs.size` or `.fs.list`. + +!!! note "Always available" + `.fs.size` and `.fs.list` are read-only and unrestricted — they remain callable for IPC peers even when the server is started with `-U`. + +## Reference + +| Function | Arity | Flags | Description | +|---|---|---|---| +| [`.fs.size`](#fs-size) | unary | — | File size in bytes. | +| [`.fs.list`](#fs-list) | unary | — | Sorted sym vector of directory entries. | + +## `.fs.size` { #fs-size } + +Signature: `(.fs.size "path")`. Returns the file size in bytes as an `i64`. + +Errors: `type` (arg not a string), `io` (path missing or names a directory rather than a file). Wrap in `try` to distinguish "doesn't exist" from "is a directory" without parsing error messages — both surface as `io`. + +```lisp +(.fs.size "/etc/hosts") +;; => 213 + +(try (.fs.size "/missing")) +;; => +``` + +## `.fs.list` { #fs-list } + +Signature: `(.fs.list "path")`. Returns a sym vector of entries in the directory, sorted lexicographically. The `.` and `..` entries are filtered out. + +Errors: `type` (arg not a string), `io` (path is not a directory, or doesn't exist). + +```lisp +(.fs.list "/etc") +;; => ['hosts 'passwd 'resolv.conf ...] +``` + +Use this as a building block — `find`-style recursive walks can be expressed by mapping `.fs.list` over the result and filtering with `.fs.size` / `try`. + +## See also + +- [`.os.*`](os.md) — process-environment access (`getenv` / `setenv`). +- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here (test predicates, deletes, recursive operations). diff --git a/docs/docs/namespaces/index.md b/docs/docs/namespaces/index.md index d7e07605..94dbc157 100644 --- a/docs/docs/namespaces/index.md +++ b/docs/docs/namespaces/index.md @@ -9,11 +9,12 @@ Rayfall's builtins are organised under dotted namespaces. Names beginning with ` | [`.col.*`](col.md) | Foreign-key style column-to-table linking and dotted dereference. | | [`.csv.*`](csv.md) | CSV import/export — in-memory, splayed-on-disk, and partitioned variants. | | [`.db.*`](db.md) | On-disk table I/O: splayed and partitioned `get` / `set`. | +| [`.fs.*`](fs.md) | Filesystem metadata: `size`, `list`. | | [`.graph.*`](graph.md) | Graph builders and algorithms (PageRank, Louvain, Dijkstra, MST, BFS/DFS, expand, …). | | [`.idx.*`](idx.md) | Accelerator indexes: bloom, hash, sort, zone. | | [`.ipc.*`](ipc.md) | TCP client IPC and the server connection-hook accessor. | | [`.log.*`](log.md) | Write-ahead log: open, write, sync, snapshot, roll, replay, validate. | -| [`.os.*`](os.md) | Filesystem and environment: `getenv`, `setenv`, `list`, `size`. | +| [`.os.*`](os.md) | Process environment: `getenv`, `setenv`. | | [`.repl.*`](repl.md) | Interactive REPL control — attach the local REPL to a remote server. | | [`.sys.*`](sys.md) | System info and shell-style commands: build, info, mem, gc, exec, listen, timeit, env, cmd. | | [`.time.*`](time.md) | Monotonic clock and timer scheduler. | @@ -31,4 +32,4 @@ When the server is started with `-U `, the following dotted builtins a - `.sys.exec`, `.sys.cmd`, `.sys.listen` - `.time.timer.set`, `.time.timer.del` -`.col.*`, `.graph.*`, `.idx.*`, and pure read/inspect builtins (`.db.splayed.get`, `.db.parted.get`, `.db.parted.tables`, `.os.size`, `.os.list`, `.log.write`, `.log.sync`, `.log.validate`, `.sys.build`, `.sys.info`, `.sys.mem`, `.sys.gc`, `.sys.env`, `.sys.timeit`, `.ipc.handle`, `.time.now`) are always available. +`.col.*`, `.graph.*`, `.idx.*`, and pure read/inspect builtins (`.db.splayed.get`, `.db.parted.get`, `.db.parted.tables`, `.fs.size`, `.fs.list`, `.log.write`, `.log.sync`, `.log.validate`, `.sys.build`, `.sys.info`, `.sys.mem`, `.sys.gc`, `.sys.env`, `.sys.timeit`, `.ipc.handle`, `.time.now`) are always available. diff --git a/docs/docs/namespaces/os.md b/docs/docs/namespaces/os.md index 049cc98a..62f7dffc 100644 --- a/docs/docs/namespaces/os.md +++ b/docs/docs/namespaces/os.md @@ -1,9 +1,9 @@ -# `.os.*` — filesystem and environment +# `.os.*` — process environment -Minimal filesystem and process-environment primitives. The namespace is deliberately small: four builtins cover the common needs (read/write env, file size, directory listing) and the predicate cases (exists / is-file / is-dir) are reached by `try`-wrapping `.os.size` or `.os.list`. +Process-environment access. The namespace is deliberately small: two builtins read and write environment variables. Filesystem metadata lives in its own [`.fs.*`](fs.md) namespace. !!! note "Restricted under `-U`" - `.os.getenv` and `.os.setenv` are `RAY_FN_RESTRICTED` (environment is process-global state). `.os.size` and `.os.list` are read-only and unrestricted. + `.os.getenv` and `.os.setenv` are `RAY_FN_RESTRICTED` (environment is process-global state). ## Reference @@ -11,8 +11,6 @@ Minimal filesystem and process-environment primitives. The namespace is delibera |---|---|---|---| | [`.os.getenv`](#os-getenv) | unary | restricted | Read an environment variable as a string. | | [`.os.setenv`](#os-setenv) | binary | restricted | Set an environment variable. | -| [`.os.size`](#os-size) | unary | — | File size in bytes. | -| [`.os.list`](#os-list) | unary | — | Sorted sym vector of directory entries. | ## `.os.getenv` { #os-getenv } @@ -38,33 +36,7 @@ Errors: `type` (either arg not a string), `domain` (null pointer). (.os.setenv "RAY_DEBUG" "1") ``` -## `.os.size` { #os-size } - -Signature: `(.os.size "path")`. Returns the file size in bytes as an `i64`. - -Errors: `type` (arg not a string), `io` (path missing or names a directory rather than a file). Wrap in `try` to distinguish "doesn't exist" from "is a directory" without parsing error messages — both surface as `io`. - -```lisp -(.os.size "/etc/hosts") -;; => 213 - -(try (.os.size "/missing")) -;; => -``` - -## `.os.list` { #os-list } - -Signature: `(.os.list "path")`. Returns a sym vector of entries in the directory, sorted lexicographically. The `.` and `..` entries are filtered out. - -Errors: `type` (arg not a string), `io` (path is not a directory, or doesn't exist). - -```lisp -(.os.list "/etc") -;; => ['hosts 'passwd 'resolv.conf ...] -``` - -Use this as a building block — `find`-style recursive walks can be expressed by mapping `.os.list` over the result and filtering with `.os.size` / `try`. - ## See also -- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here (test predicates, deletes, recursive operations). +- [`.fs.*`](fs.md) — filesystem metadata (`size` / `list`). +- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here. diff --git a/mkdocs.yml b/mkdocs.yml index 808bfaef..47a5cfd9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,11 +126,12 @@ nav: - .col — Column linking: docs/namespaces/col.md - .csv — CSV I/O: docs/namespaces/csv.md - .db — Tables on disk: docs/namespaces/db.md + - .fs — Filesystem: docs/namespaces/fs.md - .graph — Graph algorithms: docs/namespaces/graph.md - .idx — Indexes: docs/namespaces/idx.md - .ipc — IPC client: docs/namespaces/ipc.md - .log — Write-ahead log: docs/namespaces/log.md - - .os — Filesystem & env: docs/namespaces/os.md + - .os — Process env: docs/namespaces/os.md - .repl — REPL control: docs/namespaces/repl.md - .sys — System info: docs/namespaces/sys.md - .time — Clock & timers: docs/namespaces/time.md diff --git a/src/lang/eval.c b/src/lang/eval.c index 708de14b..056e5bc9 100644 --- a/src/lang/eval.c +++ b/src/lang/eval.c @@ -2929,11 +2929,12 @@ static void ray_register_builtins(void) { /* OS env / process interaction under `.os.*` */ register_unary( ".os.getenv", RAY_FN_RESTRICTED, ray_getenv_fn); register_binary(".os.setenv", RAY_FN_RESTRICTED, ray_setenv_fn); - /* Filesystem metadata (issue #36): size + listing. Predicates - * (exists / is-file / is-dir) are reachable via `try` on these - * or via shell fallback through `.sys.cmd`. */ - register_unary( ".os.size", RAY_FN_NONE, ray_os_size_fn); - register_unary( ".os.list", RAY_FN_NONE, ray_os_list_fn); + + /* Filesystem metadata under `.fs.*` (issue #36): size + listing. + * Predicates (exists / is-file / is-dir) are reachable via `try` on + * these or via shell fallback through `.sys.cmd`. */ + register_unary( ".fs.size", RAY_FN_NONE, ray_fs_size_fn); + register_unary( ".fs.list", RAY_FN_NONE, ray_fs_list_fn); /* IPC client primitives under `.ipc.*` */ register_vary( ".ipc.open", RAY_FN_RESTRICTED, ray_hopen_fn); diff --git a/src/lang/internal.h b/src/lang/internal.h index 25625ab7..7571e28d 100644 --- a/src/lang/internal.h +++ b/src/lang/internal.h @@ -536,11 +536,11 @@ ray_t* ray_sys_listen_fn(ray_t* x); ray_t* ray_sys_timeit_fn(ray_t** args, int64_t n); ray_t* ray_sys_env_fn(ray_t** args, int64_t n); ray_t* ray_getenv_fn(ray_t* x); -/* Filesystem metadata under .os.* (issue #36). Lean two: size + +/* Filesystem metadata under .fs.* (issue #36). Lean two: size + * directory-list. Existence/is-file/is-dir reachable via try on * either of these, or via the shell fallback in .sys.cmd. */ -ray_t* ray_os_size_fn(ray_t* x); -ray_t* ray_os_list_fn(ray_t* x); +ray_t* ray_fs_size_fn(ray_t* x); +ray_t* ray_fs_list_fn(ray_t* x); ray_t* ray_setenv_fn(ray_t* name, ray_t* val); ray_t* ray_quote_fn(ray_t** args, int64_t n); ray_t* ray_return_fn(ray_t** args, int64_t n); diff --git a/src/ops/system.c b/src/ops/system.c index f0bd828f..16a5b957 100644 --- a/src/ops/system.c +++ b/src/ops/system.c @@ -236,15 +236,15 @@ ray_t* ray_fill_parted_fn(ray_t** args, int64_t n) { return ray_parted_fill(root); } -/* stat/dirent used by the .os.* filesystem metadata builtins below. */ +/* stat/dirent used by the .fs.* filesystem metadata builtins below. */ #include #include /* ══════════════════════════════════════════ - * Filesystem metadata: .os.size / .os.list + * Filesystem metadata: .fs.size / .fs.list * * Issue #36 asked for size + existence + listing primitives. We - * keep just two — `.os.size` and `.os.list` — because every other + * keep just two — `.fs.size` and `.fs.list` — because every other * predicate (exists, is-file, is-dir) is reachable either via * try-on-error against these or via the existing shell fallback * (`(.sys.cmd "test -e p")` etc.). Both errors are flagged "io" @@ -253,19 +253,19 @@ ray_t* ray_fill_parted_fn(ray_t** args, int64_t n) { * message. * ══════════════════════════════════════════ */ -/* (.os.size "path") → i64 file size in bytes. Errors with "io" +/* (.fs.size "path") → i64 file size in bytes. Errors with "io" * when the path doesn't exist or names a directory — `try` it if * the caller wants those treated as "not a file" rather than a * hard error. */ -ray_t* ray_os_size_fn(ray_t* x) { +ray_t* ray_fs_size_fn(ray_t* x) { if (!ray_is_atom(x) || x->type != -RAY_STR) - return ray_error("type", ".os.size expects a string path"); + return ray_error("type", ".fs.size expects a string path"); char path[1024]; /* x is already a STR atom here; str_to_cpath fails only when the path * is empty or exceeds the buffer — a domain/range issue, not a type * mismatch (old code: "type" → "domain"). */ if (!str_to_cpath(x, path, sizeof(path))) - return ray_error("domain", ".os.size path is empty or too long, got %lld bytes", (long long)ray_str_len(x)); + return ray_error("domain", ".fs.size path is empty or too long, got %lld bytes", (long long)ray_str_len(x)); struct stat st; if (stat(path, &st) != 0) @@ -284,19 +284,19 @@ static int dir_entry_cmp(const void* a, const void* b) { return strcmp(sa, sb); } -/* (.os.list "path") → sym vec of entries, sorted, with `.` and `..` +/* (.fs.list "path") → sym vec of entries, sorted, with `.` and `..` * filtered out. Errors with "io" if the path isn't a directory or * doesn't exist — caller can use that as a file/dir discriminator * via `try` when they don't want to shell out for the predicate. */ -ray_t* ray_os_list_fn(ray_t* x) { +ray_t* ray_fs_list_fn(ray_t* x) { if (!ray_is_atom(x) || x->type != -RAY_STR) - return ray_error("type", ".os.list expects a string path"); + return ray_error("type", ".fs.list expects a string path"); char path[1024]; /* x is already a STR atom here; str_to_cpath fails only when the path * is empty or exceeds the buffer — a domain/range issue, not a type * mismatch (old code: "type" → "domain"). */ if (!str_to_cpath(x, path, sizeof(path))) - return ray_error("domain", ".os.list path is empty or too long, got %lld bytes", (long long)ray_str_len(x)); + return ray_error("domain", ".fs.list path is empty or too long, got %lld bytes", (long long)ray_str_len(x)); DIR* d = opendir(path); if (!d) return ray_error("io", "%s: %s", path, strerror(errno)); diff --git a/test/rfl/system/db_sym_resolution.rfl b/test/rfl/system/db_sym_resolution.rfl index 87b9b25f..daed3ae8 100644 --- a/test/rfl/system/db_sym_resolution.rfl +++ b/test/rfl/system/db_sym_resolution.rfl @@ -14,16 +14,16 @@ (set H1 (table [s v] (list [aapl goog] [4 5]))) (.db.splayed.set "/tmp/rfl_symres/2024.01.01/hist/" H0) (.db.splayed.set "/tmp/rfl_symres/2024.01.02/hist/" H1) -(> (.os.size "/tmp/rfl_symres/.sym") 0) -- true +(> (.fs.size "/tmp/rfl_symres/.sym") 0) -- true ;; no per-partition symfiles exist (partitions have no own domain) -(.os.size "/tmp/rfl_symres/2024.01.01/hist/.sym") !- io -(.os.size "/tmp/rfl_symres/2024.01.02/hist/.sym") !- io +(.fs.size "/tmp/rfl_symres/2024.01.01/hist/.sym") !- io +(.fs.size "/tmp/rfl_symres/2024.01.02/hist/.sym") !- io (count (.db.parted.get "/tmp/rfl_symres/" 'hist)) -- 5 ;; ── 2. a standalone splayed table writes dir/.sym ──────────────────────── (set Solo (table [s] (list [solo_a solo_b]))) (.db.splayed.set "/tmp/rfl_symres_solo/t" Solo) -(> (.os.size "/tmp/rfl_symres_solo/t/.sym") 0) -- true +(> (.fs.size "/tmp/rfl_symres_solo/t/.sym") 0) -- true (at (at (.db.splayed.get "/tmp/rfl_symres_solo/t") 's) 1) -- 'solo_b ;; ── 3. NO ROOT-WALK for plain dirs (the post-flip difference) ─────────── @@ -31,13 +31,13 @@ ;; up implicitly. Default write -> dir/.sym created next to the data: (set L (table [s p] (list [msft tsla] [10.0 30.0]))) (.db.splayed.set "/tmp/rfl_symres/live" L) -(> (.os.size "/tmp/rfl_symres/live/.sym") 0) -- true +(> (.fs.size "/tmp/rfl_symres/live/.sym") 0) -- true (at (at (.db.splayed.get "/tmp/rfl_symres/live") 's) 1) -- 'tsla ;; Sharing the root sym requires the EXPLICIT argument: (.db.splayed.set "/tmp/rfl_symres/live2" L "/tmp/rfl_symres/.sym") ;; no dir-local symfile was written for the shared table... -(.os.size "/tmp/rfl_symres/live2/.sym") !- io +(.fs.size "/tmp/rfl_symres/live2/.sym") !- io ;; ...and the default READ must not walk to the root either: nothing ;; resolves -> loud sym error, even though /tmp/rfl_symres/.sym exists (.db.splayed.get "/tmp/rfl_symres/live2") !- sym @@ -58,13 +58,13 @@ ;; a decoy (save sweeps stale columns but never symfiles); an explicit ;; read must use the explicit path, not the decoy. (.db.splayed.set "/tmp/rfl_symres/live" L "/tmp/rfl_symres/.sym") -(> (.os.size "/tmp/rfl_symres/live/.sym") 0) -- true +(> (.fs.size "/tmp/rfl_symres/live/.sym") 0) -- true (at (at (.db.splayed.get "/tmp/rfl_symres/live" "/tmp/rfl_symres/.sym") 's) 1) -- 'tsla ;; ── 5. symbol-free table: no symfile written, loads fine ──────────────── (set N (table [a b] (list [1 2 3] [1.5 2.5 3.5]))) (.db.splayed.set "/tmp/rfl_symres_nosym/t" N) -(.os.size "/tmp/rfl_symres_nosym/t/.sym") !- io +(.fs.size "/tmp/rfl_symres_nosym/t/.sym") !- io (count (.db.splayed.get "/tmp/rfl_symres_nosym/t")) -- 3 (sum (at (.db.splayed.get "/tmp/rfl_symres_nosym/t") 'a)) -- 6 @@ -81,7 +81,7 @@ (count (distinct (as 'symbol (til 500)))) -- 500 (set V (table [s] (list [va vb vc]))) (.db.splayed.set "/tmp/rfl_symres_vocab/t" V) -(< (.os.size "/tmp/rfl_symres_vocab/t/.sym") 100) -- true +(< (.fs.size "/tmp/rfl_symres_vocab/t/.sym") 100) -- true (at (at (.db.splayed.get "/tmp/rfl_symres_vocab/t") 's) 2) -- 'vc ;; ── cleanup ────────────────────────────────────────────────────────────── diff --git a/test/rfl/system/os_fs.rfl b/test/rfl/system/os_fs.rfl index 6a546364..9bccd27e 100644 --- a/test/rfl/system/os_fs.rfl +++ b/test/rfl/system/os_fs.rfl @@ -1,4 +1,4 @@ -;; .os.size and .os.list — filesystem metadata primitives, issue #36. +;; .fs.size and .fs.list — filesystem metadata primitives, issue #36. ;; ;; Two functions on purpose: every other predicate (exists, is-file, ;; is-dir) is reachable via `try` against these or via the shell @@ -15,41 +15,41 @@ (.sys.exec "printf 'hello world\\n' > /tmp/rf_os_fs_test/file.txt") (.sys.exec "touch /tmp/rf_os_fs_test/sub/a /tmp/rf_os_fs_test/sub/c /tmp/rf_os_fs_test/sub/b") -;; ────────────── .os.size: regular file ────────────── -(.os.size "/tmp/rf_os_fs_test/file.txt") -- 12 ;; "hello world\n" +;; ────────────── .fs.size: regular file ────────────── +(.fs.size "/tmp/rf_os_fs_test/file.txt") -- 12 ;; "hello world\n" -;; ────────────── .os.size: directory and missing both error ────────────── +;; ────────────── .fs.size: directory and missing both error ────────────── ;; Same error category ("io") so try/catch can treat them uniformly ;; — the error message distinguishes when a human is looking. -(.os.size "/tmp/rf_os_fs_test") !- io -(.os.size "/tmp/rf_os_fs_test/nope.txt") !- io +(.fs.size "/tmp/rf_os_fs_test") !- io +(.fs.size "/tmp/rf_os_fs_test/nope.txt") !- io -;; ────────────── .os.list: directory entries, sorted ────────────── +;; ────────────── .fs.list: directory entries, sorted ────────────── ;; readdir order is filesystem-defined; we sort so tests are stable ;; and `..` / `.` are filtered. The fixture above writes a c b in ;; non-sorted order to prove the sort actually runs. -(.os.list "/tmp/rf_os_fs_test/sub") -- ['a 'b 'c] -(.os.list "/tmp/rf_os_fs_test") -- ['file.txt 'sub] +(.fs.list "/tmp/rf_os_fs_test/sub") -- ['a 'b 'c] +(.fs.list "/tmp/rf_os_fs_test") -- ['file.txt 'sub] -;; ────────────── .os.list: file and missing both error ────────────── -(.os.list "/tmp/rf_os_fs_test/file.txt") !- io -(.os.list "/tmp/rf_os_fs_test/nope") !- io +;; ────────────── .fs.list: file and missing both error ────────────── +(.fs.list "/tmp/rf_os_fs_test/file.txt") !- io +(.fs.list "/tmp/rf_os_fs_test/nope") !- io ;; ────────────── existence-check via try ────────────── ;; The motivating idiom: probe size, swallow the error. Truthy → ;; the path is a regular file with a knowable size. -(try (>= (.os.size "/tmp/rf_os_fs_test/file.txt") 0) (fn [_] false)) -- true -(try (>= (.os.size "/tmp/rf_os_fs_test/nope") 0) (fn [_] false)) -- false +(try (>= (.fs.size "/tmp/rf_os_fs_test/file.txt") 0) (fn [_] false)) -- true +(try (>= (.fs.size "/tmp/rf_os_fs_test/nope") 0) (fn [_] false)) -- false ;; ────────────── is-dir discriminator via try ────────────── -;; .os.list errors on non-directories, so a successful list (even +;; .fs.list errors on non-directories, so a successful list (even ;; empty) is the directory marker. -(try (do (.os.list "/tmp/rf_os_fs_test/sub") true) (fn [_] false)) -- true -(try (do (.os.list "/tmp/rf_os_fs_test/file.txt") true) (fn [_] false)) -- false +(try (do (.fs.list "/tmp/rf_os_fs_test/sub") true) (fn [_] false)) -- true +(try (do (.fs.list "/tmp/rf_os_fs_test/file.txt") true) (fn [_] false)) -- false ;; ────────────── argument validation ────────────── -(.os.size 5) !- type -(.os.list 'foo) !- type +(.fs.size 5) !- type +(.fs.list 'foo) !- type ;; ────────────── teardown ────────────── (.sys.exec "rm -rf /tmp/rf_os_fs_test") diff --git a/test/rfl/system/reserved_namespace.rfl b/test/rfl/system/reserved_namespace.rfl index 2bbcabc8..1a63230c 100644 --- a/test/rfl/system/reserved_namespace.rfl +++ b/test/rfl/system/reserved_namespace.rfl @@ -18,12 +18,15 @@ ;; with leading-dot magic" bookkeeping. (type .sys) -- 'DICT (type .os) -- 'DICT +(type .fs) -- 'DICT (type .ipc) -- 'DICT (type .csv) -- 'DICT (count .sys) -- 10 ;; gc, exec, build, mem, info, cmd, timeit, listen, env, args -(count .os) -- 4 -;; getenv, setenv, size, list +(count .os) -- 2 +;; getenv, setenv +(count .fs) -- 2 +;; size, list (count .ipc) -- 5 ;; open, close, send, post, handle (count .csv) -- 4 @@ -53,6 +56,8 @@ (nil? (resolve '.sys.gc)) -- false (nil? (resolve '.sys.info)) -- false (nil? (resolve '.os.getenv)) -- false +(nil? (resolve '.fs.size)) -- false +(nil? (resolve '.fs.list)) -- false (nil? (resolve '.csv.read)) -- false (nil? (resolve '.csv.splayed)) -- false (nil? (resolve '.csv.parted)) -- false diff --git a/test/rfl/system/system_branch_cov.rfl b/test/rfl/system/system_branch_cov.rfl index 09c0ac34..32adf1dd 100644 --- a/test/rfl/system/system_branch_cov.rfl +++ b/test/rfl/system/system_branch_cov.rfl @@ -62,40 +62,40 @@ ;; ══════════════════════════════════════════════════════════════════════ ;; ray_os_size_fn (lines 338-349) ;; ══════════════════════════════════════════════════════════════════════ -(.os.size [1 2 3]) !- type -(.os.size 'foo) !- type -(.os.size "/tmp/rfl_sys_bc_nofile.txt") !- io -(.os.size "/tmp") !- io +(.fs.size [1 2 3]) !- type +(.fs.size 'foo) !- type +(.fs.size "/tmp/rfl_sys_bc_nofile.txt") !- io +(.fs.size "/tmp") !- io (.sys.exec "touch /tmp/rfl_sys_bc_empty.txt") -(.os.size "/tmp/rfl_sys_bc_empty.txt") -- 0 +(.fs.size "/tmp/rfl_sys_bc_empty.txt") -- 0 (.sys.exec "printf 'abcdef' > /tmp/rfl_sys_bc_6bytes.txt") -(.os.size "/tmp/rfl_sys_bc_6bytes.txt") -- 6 +(.fs.size "/tmp/rfl_sys_bc_6bytes.txt") -- 6 ;; ══════════════════════════════════════════════════════════════════════ ;; ray_os_list_fn (lines 365-425) ;; ══════════════════════════════════════════════════════════════════════ -(.os.list [1 2]) !- type -(.os.list 42) !- type -(.os.list "/tmp/rfl_sys_bc_nosuchdir/") !- io -(.os.list "/tmp/rfl_sys_bc_empty.txt") !- io +(.fs.list [1 2]) !- type +(.fs.list 42) !- type +(.fs.list "/tmp/rfl_sys_bc_nosuchdir/") !- io +(.fs.list "/tmp/rfl_sys_bc_empty.txt") !- io ;; Directory with a single file (.sys.exec "mkdir -p /tmp/rfl_sys_bc_onefile/ && touch /tmp/rfl_sys_bc_onefile/x") -(count (.os.list "/tmp/rfl_sys_bc_onefile/")) -- 1 -(.os.list "/tmp/rfl_sys_bc_onefile/") -- [x] +(count (.fs.list "/tmp/rfl_sys_bc_onefile/")) -- 1 +(.fs.list "/tmp/rfl_sys_bc_onefile/") -- [x] ;; dot-prefixed entries (not . or ..) ARE listed (.sys.exec "touch /tmp/rfl_sys_bc_onefile/.hidden") -(count (.os.list "/tmp/rfl_sys_bc_onefile/")) -- 2 +(count (.fs.list "/tmp/rfl_sys_bc_onefile/")) -- 2 ;; sorted symbol vector: ".hidden" sorts before "x" -(.os.list "/tmp/rfl_sys_bc_onefile/") -- ['.hidden 'x] +(.fs.list "/tmp/rfl_sys_bc_onefile/") -- ['.hidden 'x] ;; > 16 entries triggers realloc growth path (.sys.exec "mkdir -p /tmp/rfl_sys_bc_bigdir && cd /tmp/rfl_sys_bc_bigdir && for i in $(seq 1 20); do touch f_$(printf '%02d' $i); done") -(count (.os.list "/tmp/rfl_sys_bc_bigdir/")) -- 20 +(count (.fs.list "/tmp/rfl_sys_bc_bigdir/")) -- 20 ;; entries are sorted: first is f_01, last is f_20 -(first (.os.list "/tmp/rfl_sys_bc_bigdir/")) -- 'f_01 -(last (.os.list "/tmp/rfl_sys_bc_bigdir/")) -- 'f_20 +(first (.fs.list "/tmp/rfl_sys_bc_bigdir/")) -- 'f_01 +(last (.fs.list "/tmp/rfl_sys_bc_bigdir/")) -- 'f_20 ;; Empty directory — previously triggered qsort(NULL, 0, ...) UB. (.sys.exec "mkdir -p /tmp/rfl_sys_bc_emptydir") -(count (.os.list "/tmp/rfl_sys_bc_emptydir/")) -- 0 +(count (.fs.list "/tmp/rfl_sys_bc_emptydir/")) -- 0 (.sys.exec "rm -rf /tmp/rfl_sys_bc_emptydir") ;; ══════════════════════════════════════════════════════════════════════ From d1cdb666dbbbad0483b52608364aec31977592bd Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 24 Jun 2026 14:19:39 +0200 Subject: [PATCH 3/3] fix(store): list zero tables for an empty parted root instead of erroring collect_part_dirs conflated opendir failure (missing/unreadable path) with an existing-but-empty directory, returning RAY_ERR_IO for both. An existing root with no partition directories is a valid empty db, not an I/O error. collect_part_dirs now returns RAY_OK with count 0 in that case and each caller decides: .db.parted.tables and .db.parted.fill return an empty sym vector (the latter already documents 'empty when nothing to fix'), while .db.parted.get still errors since reading a named table from a db with no partitions has nothing to return. Missing/unreadable paths keep erroring (opendir fails -> RAY_ERR_IO). --- docs/docs/namespaces/db.md | 6 ++++-- src/store/part.c | 28 +++++++++++++++++++++++++--- test/rfl/system/db_get.rfl | 11 +++++++++-- test/test_store.c | 12 ++++++++---- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/docs/namespaces/db.md b/docs/docs/namespaces/db.md index 9de840fd..cd12cbba 100644 --- a/docs/docs/namespaces/db.md +++ b/docs/docs/namespaces/db.md @@ -81,7 +81,9 @@ Returns a sorted `sym` vector of the table names available under a parted `db_ro (map (fn [t] (.db.parted.get "/data/db" t)) (.db.parted.tables "/data/db")) ``` -Errors: `domain` (arity != 1), `type` (root not a string), `io` (root unreadable or not a parted root — no partition directories). +An existing root with no partition directories (a freshly-created or non-parted directory) lists **no tables** — the call returns an empty `sym` vector rather than failing. + +Errors: `domain` (arity != 1), `type` (root not a string), `io` (root missing or unreadable — `opendir` fails). ## `.db.parted.fill` { #db-parted-fill } @@ -100,7 +102,7 @@ Returns a sorted `sym` vector of the partition names that were filled (an **empt (select {from: (.db.parted.get "/data/db" 'news)}) ``` -The filled copies are empty, so aggregate results across the db are unchanged — only the on-disk uniformity is. Errors: `domain` (arity != 1), `type` (root not a string), `io` (not a parted root), plus any `oom`/`corrupt` surfaced while reading a template or writing a copy. +The filled copies are empty, so aggregate results across the db are unchanged — only the on-disk uniformity is. An existing root with no partition directories is a no-op: the call returns an empty `sym` vector. Errors: `domain` (arity != 1), `type` (root not a string), `io` (root missing or unreadable), plus any `oom`/`corrupt` surfaced while reading a template or writing a copy. ## See also diff --git a/src/store/part.c b/src/store/part.c index 8b936793..7fe7b399 100644 --- a/src/store/part.c +++ b/src/store/part.c @@ -167,8 +167,14 @@ static ray_err_t collect_part_dirs(const char* db_root, char*** out_dirs, } if (part_count == 0) { + /* Directory opened fine but holds no partition directories — an + * EMPTY (or non-parted) db, not an I/O failure. Report success + * with a zero count and let each caller decide what "empty" means + * (tables/fill → empty result; get → error, nothing to read). */ free(part_dirs); - return RAY_ERR_IO; + *out_dirs = NULL; + *out_count = 0; + return RAY_OK; } /* Sort partition names for deterministic order. @@ -244,6 +250,14 @@ ray_t* ray_read_parted(const char* db_root, const char* table_name) { if (trace) fprintf(stderr, "parted.get: parts=%" PRId64 "\n", part_count); + /* No partitions: there is no named table to read. Unlike + * .db.parted.tables (which lists nothing), reading data from an empty + * or non-parted root is a genuine error. */ + if (part_count <= 0) { + if (dom) ray_sym_domain_release(dom); + return ray_error("io", "parted %s: no partition directories", db_root); + } + /* Open each partition via ray_read_splayed */ ray_t* part_err = NULL; ray_t** part_tables = (ray_t**)ray_sys_alloc((size_t)part_count * sizeof(ray_t*)); @@ -569,8 +583,12 @@ ray_t* ray_parted_tables(const char* db_root) { return ray_error(ray_err_code_str(e), "parted %s: cannot enumerate partition directories", db_root); if (part_count <= 0) { + /* Existing-but-empty (or non-parted) root → no tables. Return an + * empty SYM vector rather than an error: a freshly-created db root + * has zero tables, and that's a friendlier answer than failing. */ free(part_dirs); - return ray_error("io", "parted %s: no partition directories", db_root); + ray_t* empty = ray_vec_new(RAY_SYM, 0); + return empty ? empty : ray_error("oom", NULL); } /* Only the last (most recent) partition is needed; release the rest. */ @@ -626,8 +644,12 @@ ray_t* ray_parted_fill(const char* db_root) { return ray_error(ray_err_code_str(e), "parted %s: cannot enumerate partition directories", db_root); if (part_count <= 0) { + /* Empty (or non-parted) root → nothing to fill. Matches the + * "empty vector when nothing needed fixing" contract, so a fill on + * a fresh db root is a friendly no-op rather than an error. */ free(part_dirs); - return ray_error("io", "parted %s: no partition directories", db_root); + ray_t* empty = ray_vec_new(RAY_SYM, 0); + return empty ? empty : ray_error("oom", NULL); } /* Shared root symfile — pass to load/save so SYM columns intern against diff --git a/test/rfl/system/db_get.rfl b/test/rfl/system/db_get.rfl index e491d10d..cb03cd4a 100644 --- a/test/rfl/system/db_get.rfl +++ b/test/rfl/system/db_get.rfl @@ -89,8 +89,15 @@ (count (.db.parted.tables "/tmp/rfl_get_parted/")) -- 2 ;; a discovered name feeds straight back into .db.parted.get. (count (.db.parted.get "/tmp/rfl_get_parted/" (at (.db.parted.tables "/tmp/rfl_get_parted/") 0))) -- 4 -;; not a parted root (splayed root has no partition dirs) -> io error. -(.db.parted.tables "/tmp/rfl_get_splayed/") !- io +;; existing-but-empty root (splayed root has no partition dirs) -> empty +;; list, not an error. A directory that simply isn't a parted db lists +;; zero tables; that's friendlier than failing. +(count (.db.parted.tables "/tmp/rfl_get_splayed/")) -- 0 +;; a freshly-created empty directory likewise lists nothing. +(.sys.exec "mkdir -p /tmp/rfl_get_empty") +(count (.db.parted.tables "/tmp/rfl_get_empty")) -- 0 +(.sys.exec "rm -rf /tmp/rfl_get_empty") +;; a genuinely missing directory is still an io error (opendir fails). (.db.parted.tables "/tmp/rfl_get_does_not_exist/") !- io ;; .db.parted.tables reads the LAST partition, so a table added in a later diff --git a/test/test_store.c b/test/test_store.c index 66cce1a3..aced0d41 100644 --- a/test/test_store.c +++ b/test/test_store.c @@ -744,11 +744,15 @@ static test_result_t test_parted_tables(void) { TEST_ASSERT_FALSE(RAY_IS_ERR(tbl)); ray_release(tbl); - /* A non-parted root (no partition dirs) is an error, not empty. */ + /* An existing-but-empty root (no partition dirs) lists no tables — + * an empty SYM vector, not an error. */ (void)!system("rm -rf " TMP_PART_DB "_np && mkdir -p " TMP_PART_DB "_np"); - ray_t* err = ray_parted_tables(TMP_PART_DB "_np"); - TEST_ASSERT_TRUE(err != NULL && RAY_IS_ERR(err)); - ray_error_free(err); + ray_t* empty = ray_parted_tables(TMP_PART_DB "_np"); + TEST_ASSERT_NOT_NULL(empty); + TEST_ASSERT_FALSE(RAY_IS_ERR(empty)); + TEST_ASSERT_EQ_I(empty->type, RAY_SYM); + TEST_ASSERT_EQ_I(empty->len, 0); + ray_release(empty); (void)!system("rm -rf " TMP_PART_DB "_np"); (void)!system("rm -rf " TMP_PART_DB);