Two genuine defects behind the 0128 filing (whose original repros were
both poisoned by binding getenv, which std already declares -> *u8):
1. Re-declaring a C symbol was silent first-wins: every call through
the later declaration was typed by the older signature. Foreign
registration now dedupes — equal signatures share one FuncId,
conflicting ones are diagnosed.
2. Foreign -> string / -> ?string returns read garbage: C returns one
char*, but the LLVM signature declared the fat {ptr,i64} (len =
register garbage), and ?string was mis-declared SRET (the hidden
out-pointer landed in the callee's first arg register). cstrRetKind
now classifies such returns, declares them as plain ptr (never
sret), and the call site synthesizes {ptr, strlen} via a
branch-guarded strlen (NULL -> {null,0} / optional null), wrapping
{string, i1} for ?string.
?[:0]u8 itself resolves fine (it is ?string); the spelling works in
return, param, local, and alias positions.
Regression: examples/1221 (plain + optional non-null + NULL paths) and
examples/1172 (conflict diagnostic); both FAIL pre-fix. The extern
dedupe collapses duplicate libc decls, so affected .ir snapshots were
regenerated. zig build test 426/426; run_examples 602/602;
distribution suite 21/21.
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
u8return" and the "?[:0]u8unresolved panic" were BOTH artifacts of the reproducers binding the C symbolgetenv, 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]u8itself resolves correctly (it is?string). The two GENUINE defects, both fixed:
- Conflicting same-symbol redeclaration was silent.
dedupeForeignSymbol(src/ir/lower/decl.zig) now runs at foreign registration: an EQUAL signature shares the first registration's FuncId; a CONFLICTING one is diagnosed ("foreign symbol '' is already bound with a different signature").- Foreign
-> string/-> ?stringreturns read garbage. The C side returns ONEchar *; 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 bycstrRetKind(src/ir/emit_llvm.zig), declared as plainptrreturns (never sret), and the call site synthesizes the sx value viacstrReturnToSx:{ptr, strlen(ptr)}with the strlen call branch-guarded (NULL →{null,0}), wrapped in{string, i1}withhas = ptr != nullfor the optional.Regression tests:
examples/1221-ffi-cstring-returns.sx(plain + optional non-null via strerror/strsignal + optional NULL via dlerror) andexamples/1172-diagnostics-foreign-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.irfiles were regenerated. Gates: zig build test 426/426, tests/run_examples.sh 602/602, distribution repo 21/21. Boundary: comptime-interp (#run) foreign calls are untouched, and indirect (fn-pointer) foreign 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
#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
#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
#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
- 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).
- Implement the boundary contract for returns: a foreign
-> [:0]u8/-> ?[:0]u8receives ONE pointer from C; the sx-sidestringis built by synthesizing the length (strlen) at the boundary, and for the optional a NULL pointer maps tonull. 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.