Files
sx/issues/0128-cstring-ffi-boundary-returns-and-optional.md
agra b9cfe2554f refactor(ffi-linkage): Phase 9.3/9.4 — purge 'foreign' from issues/*.md; GATE PASS
Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern,
foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl,
findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→
dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the
renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed
issues/0043-…-foreign-class-…→…-runtime-class-….

PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/
issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant
SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party).
Suite green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:18:35 +03:00

5.5 KiB

RESOLVED — 0128: [:0]u8 at FFI boundaries — conflicting symbol views, garbage string returns

RESOLVED (2026-06-12). Investigation corrected the filing: the "silent u8 return" and the "?[:0]u8 unresolved panic" were BOTH artifacts of the reproducers binding the C symbol getenv, which std/process.sx already declares as -> *u8 — the FIRST registration of a C symbol silently won and every call through the later declaration was typed by the older signature (*u8), cascading into the panic. ?[:0]u8 itself resolves correctly (it is ?string). The two GENUINE defects, both fixed:

  1. Conflicting same-symbol redeclaration was silent. dedupeExternSymbol (src/ir/lower/decl.zig) now runs at extern registration: an EQUAL signature shares the first registration's FuncId; a CONFLICTING one is diagnosed ("extern symbol '' is already bound with a different signature").
  2. Extern -> string / -> ?string returns read garbage. The C side returns ONE char *; the LLVM signature declared the fat {ptr,i64} (len = register garbage; bus error on use), and ?string (24 B struct) was mis-declared SRET — the hidden out-pointer landed in the C callee's first argument register. Now: such returns are classified by cstrRetKind (src/ir/emit_llvm.zig), declared as plain ptr returns (never sret), and the call site synthesizes the sx value via cstrReturnToSx: {ptr, strlen(ptr)} with the strlen call branch-guarded (NULL → {null,0}), wrapped in {string, i1} with has = ptr != null for the optional.

Regression tests: examples/1221-ffi-cstring-returns.sx (plain + optional non-null via strerror/strsignal + optional NULL via dlerror) and examples/1172-diagnostics-extern-symbol-conflict.sx (the getenv conflict); both FAIL on pre-fix master. The extern dedupe changes IR snapshots (duplicate libc decls collapse), so the affected .ir files were regenerated. Gates: zig build test 426/426, tests/run_examples.sh 602/602, distribution repo 21/21. Boundary: comptime-interp (#run) extern calls are untouched, and indirect (fn-pointer) extern calls don't synthesize — both can follow if ever needed.

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 extern 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_extern_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 extern RETURN silently resolves to u8

#import "modules/std.sx";

libc :: #library "c";
getenv_s :: (name: [:0]u8) -> [:0]u8 extern 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

#import "modules/std.sx";

libc :: #library "c";
getenv_opt :: (name: [:0]u8) -> ?[:0]u8 extern 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 extern -> [: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, extern 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.