From ba37d0b3935dfa30be018155d876761f35e426cd Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 12 Jun 2026 13:21:19 +0300 Subject: [PATCH] =?UTF-8?q?issues:=20file=200128=20=E2=80=94=20[:0]u8=20FF?= =?UTF-8?q?I=20returns=20silently=20u8;=20=3F[:0]u8=20unresolved=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [:0]u8 aliases string (fat) and params already ABI-thin to char*, but a foreign -> [:0]u8 return silently resolves to plain u8, and ?[:0]u8 never resolves at all (LLVM emission panic) even though ?string works. Design contract recorded: ?[:0]u8 lowers to a nullable char* at the boundary, length synthesized on the sx side; until then such returns must be diagnosed, not mis-typed. --- ...tring-ffi-boundary-returns-and-optional.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 issues/0128-cstring-ffi-boundary-returns-and-optional.md diff --git a/issues/0128-cstring-ffi-boundary-returns-and-optional.md b/issues/0128-cstring-ffi-boundary-returns-and-optional.md new file mode 100644 index 0000000..bab3399 --- /dev/null +++ b/issues/0128-cstring-ffi-boundary-returns-and-optional.md @@ -0,0 +1,90 @@ +# 0128: `[:0]u8` at FFI boundaries — silent `u8` returns, unresolvable optional + +## Design contract (Agra, 2026-06-12) + +`?[:0]u8` should lower to a `char *` at the FFI boundary — a single +nullable thin pointer — NOT a fat pointer. This is the natural sx type +for every libc/sqlite-style API that returns a nullable C string +(`getenv`, `sqlite3_errmsg`, `sqlite3_column_text`, ...). + +## Current state (verified at master 1bc60d3) + +`[:0]u8` is an ALIAS for `string` (src/types.zig:145 — the `'['` arm +returns `.string_type`), i.e. a fat ptr+len value at the sx level. At a +`#foreign` PARAM position the C-ABI lowering already thins it: sx +`string`/slices coerce to a single pointer and the length is dropped +(src/backend/llvm/abi.zig, the `is_foreign_c_api` knob) — so +`popen :: (cmd: [:0]u8, ...)` works and matches the design contract. + +The other two boundary positions are broken: + +### Defect A — `-> [:0]u8` foreign RETURN silently resolves to `u8` + +```sx +#import "modules/std.sx"; + +libc :: #library "c"; +getenv_s :: (name: [:0]u8) -> [:0]u8 #foreign libc "getenv"; + +main :: () -> i32 { + v := getenv_s("PATH"); + print("len={}\n", v.len); // error: field 'len' not found on type 'u8' + return 0; +} +``` + +The declared return type `[:0]u8` resolves to plain `u8` — not the +`string` alias, not an error. Whatever the return-position type-name +path is, it disagrees with the table in types.zig and fails SILENTLY +into a wrong type (the silent-fallback-default class this repo's +CLAUDE.md forbids). + +### Defect B — `?[:0]u8` does not resolve; panics LLVM emission + +```sx +#import "modules/std.sx"; + +libc :: #library "c"; +getenv_opt :: (name: [:0]u8) -> ?[:0]u8 #foreign libc "getenv"; + +main :: () -> i32 { + p := getenv_opt("PATH"); + if p == null { return 1; } + return 0; +} +``` + +Panics: "unresolved type reached LLVM emission" +(src/backend/llvm/types.zig:175). Meanwhile plain `?string` resolves +and works at the sx level — the `'?'` arm (types.zig:141) stores +`child_name = "[:0]u8"`, and whatever resolves the child later doesn't +go through the same table that maps the bracket spelling to +`string_type`. Minimum bar even without the feature: a diagnostic, not +a panic. + +## Fix shape + +1. Make the bracket spelling resolve identically everywhere — return + position and optional child position must hit the same alias table + that param position does (Defect A and the resolution half of B). +2. Implement the boundary contract for returns: a foreign + `-> [:0]u8` / `-> ?[:0]u8` receives ONE pointer from C; the sx-side + `string` is built by synthesizing the length (strlen) at the + boundary, and for the optional a NULL pointer maps to `null`. + If (2) is deferred, foreign string/optional-string RETURNS must be + rejected with a diagnostic naming the workaround (`?*u8`). + +## Workaround in use + +/Users/agra/projects/distribution/src/db/sqlite.sx declares every +nullable C-string return as `?*u8` (null-pointer niche verified working +in both JIT and AOT) and copies via a manual strlen helper; its header +comment cites this issue's gap. std's own `getenv :: -> *u8` + +cast-check is the same dodge. Both can migrate to `?[:0]u8` once the +contract lands. + +## Verification + +Both reproducers behave: A prints a real PATH length; B answers null / +non-null correctly for missing / present variables. `zig build test`, +`tests/run_examples.sh` green; pin both as examples.