fix(ffi): replace silent .void arg-type fallback with loud .unresolved (issue 0074)
Four FFI call-arg lowering sites resolved an argument's IR type via `getRefIRType(arg_ref) orelse .void` — a silent fallback to the load-bearing real type `.void`. A failed lookup there is a codegen invariant violation, but `.void` is treated by downstream `toLLVMType` → `abiCoerceParamType` → `coerceArg` as a legitimate void-typed foreign argument, corrupting the call ABI with no diagnostic. Add one shared resolver `LLVMEmitter.argIRTypeOrFail` that returns the dedicated `.unresolved` sentinel on a failed lookup — never `.void`/`.s64` — so the failure cannot masquerade as a real type and trips `toLLVMType`'s existing hard `@panic` tripwire at the call site. Route all four sites through it: - src/ir/emit_llvm.zig JNI constructor (NewObject) arg loop - src/backend/llvm/ops.zig objc_msgSend arg loop - src/backend/llvm/ops.zig JNI non-virtual call arg loop - src/backend/llvm/ops.zig JNI Call<Type>Method arg loop Happy path is byte-identical (every real arg already has a resolved type); FFI examples stay green with zero snapshot churn. Regression test (fail-before/pass-after) in src/ir/emit_llvm.test.zig asserts an unresolvable FFI arg ref now yields `.unresolved`, not the old silent `.void`.
This commit is contained in:
84
issues/0074-ffi-arg-type-void-fallback.md
Normal file
84
issues/0074-ffi-arg-type-void-fallback.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 0074 — silent `getRefIRType(arg_ref) orelse .void` fallback in FFI call-arg lowering
|
||||
|
||||
> **✅ RESOLVED.** Root cause: four FFI call-arg lowering loops resolved an
|
||||
> argument's IR type via `getRefIRType(arg_ref) orelse .void` — a silent fallback
|
||||
> to the load-bearing real type `.void`, which downstream `toLLVMType` →
|
||||
> `abiCoerceParamType` → `coerceArg` treat as a legitimate (void-typed) foreign
|
||||
> argument, corrupting the call ABI with no diagnostic. Fix: one shared resolver
|
||||
> `LLVMEmitter.argIRTypeOrFail` ([src/ir/emit_llvm.zig]) returns the dedicated
|
||||
> `.unresolved` sentinel on a failed lookup — never `.void`/`.s64` — so the failure
|
||||
> cannot masquerade as a real type and trips `toLLVMType`'s existing hard `@panic`
|
||||
> tripwire at the call site. All four sites
|
||||
> ([src/ir/emit_llvm.zig] JNI constructor; [src/backend/llvm/ops.zig] objc_msgSend,
|
||||
> JNI non-virtual, JNI `Call<Type>Method`) now route through the helper. Happy path
|
||||
> is byte-identical (every real arg already has a resolved type) — FFI examples stay
|
||||
> green with zero snapshot churn. Regression test (fail-before/pass-after):
|
||||
> `src/ir/emit_llvm.test.zig` — "argIRTypeOrFail surfaces .unresolved for an
|
||||
> unresolvable FFI arg ref (issue 0074)".
|
||||
|
||||
## Symptom
|
||||
**One-line:** Four FFI call-arg lowering sites silently default a failed
|
||||
argument-type lookup to `.void` — the forbidden silent-type-fallback anti-pattern
|
||||
(`.void` as a failed-type-lookup sentinel), which would produce a void-typed
|
||||
foreign-call argument (wrong LLVM param type → silent ABI corruption) with no
|
||||
diagnostic if the lookup ever fails.
|
||||
|
||||
**Observed:** `self.getRefIRType(arg_ref) orelse .void` at:
|
||||
- `src/ir/emit_llvm.zig:2463`
|
||||
- `src/backend/llvm/ops.zig:517` (Obj-C `objc_msgSend` arg loop)
|
||||
- `src/backend/llvm/ops.zig:731` (JNI non-virtual call arg loop)
|
||||
- `src/backend/llvm/ops.zig:761` (JNI `Call<Type>Method` arg loop)
|
||||
|
||||
Each then does `toLLVMType(raw_ty)` → `abiCoerceParamType` → `coerceArg`, so a
|
||||
`.void` fallback silently mis-types the foreign-call argument.
|
||||
|
||||
**Expected:** `getRefIRType` returning null for a real call argument is a
|
||||
"must-succeed lookup" failure (every arg is a valid param/instruction ref). Per
|
||||
`CLAUDE.md` REJECTED PATTERNS — *"`.void` is an UNACCEPTABLE sentinel for a failed
|
||||
type lookup"* — the lookup failure must surface as a diagnostic / hard tripwire, not
|
||||
a silently-corrupted argument type.
|
||||
|
||||
## Provenance / scope
|
||||
Pre-existing pattern (the `emit_llvm.zig` site is original; the three `ops.zig` sites
|
||||
were relocated **verbatim, behavior-preserving** by step A7.4c of the arch-refactor —
|
||||
the flow reviewer/observer correctly approved the relocations as equivalence-preserving).
|
||||
Surfaced by the **A9.3 final fallback-audit** of the arch-refactor stream. Not
|
||||
introduced by the refactor; filed per the IMPASSIBLE RULE (*"If you find an existing
|
||||
default-return in the compiler that swallows a lookup failure, treat it as a
|
||||
discovered bug — file an issue, do not just delete the default in place"*).
|
||||
|
||||
## Reproduction
|
||||
This is a **latent / static** finding: there is no known sx program that drives
|
||||
`getRefIRType` to `null` for a valid foreign-call argument (well-formed IR always
|
||||
has a type for every arg ref), so it cannot currently be triggered at runtime — which
|
||||
is exactly why it is dangerous (a future IR change that breaks the invariant would
|
||||
corrupt FFI ABI silently). The code paths are exercised (and must stay green after
|
||||
the fix) by the existing FFI examples, e.g.:
|
||||
|
||||
```
|
||||
examples/13xx-ffi-objc-* # objc_msgSend arg lowering (ops.zig:517)
|
||||
examples/14xx-ffi-jni-* # JNI Call<Type>Method / non-virtual (ops.zig:731/761)
|
||||
```
|
||||
|
||||
(No new minimal repro `.sx` is meaningful for a latent defensive fallback; the fix is
|
||||
verified by (a) the FFI suite staying green and (b) a unit test that asserts the new
|
||||
loud-failure path, see below.)
|
||||
|
||||
## Investigation prompt (ready to paste into a fresh session)
|
||||
> In `/Users/agra/projects/sx`, four FFI call-arg lowering sites use the forbidden
|
||||
> silent type-fallback `self.getRefIRType(arg_ref) orelse .void`
|
||||
> (`src/ir/emit_llvm.zig:2463`; `src/backend/llvm/ops.zig:517`, `:731`, `:761`).
|
||||
> `getRefIRType` (`src/ir/emit_llvm.zig:2229`, returns `?TypeId`) yields `null` only
|
||||
> when a ref is neither a function param nor a block instruction result — a
|
||||
> must-not-happen case for a real call argument. Replace the silent `.void` default
|
||||
> with a loud failure that cannot be mistaken for a real type, per `CLAUDE.md`
|
||||
> REJECTED PATTERNS: emit a diagnostic via `self.diagnostics.addFmt(.err, span,
|
||||
> "...", .{...})` and/or a hard tripwire (`@panic`/`bailDetail`-style) naming the op
|
||||
> and the bad ref — do NOT substitute another real type. Prefer a single shared
|
||||
> helper (e.g. `argIRTypeOrFail(arg_ref, span)`) used by all four sites so the policy
|
||||
> lives in one place. Then: (1) `/Users/agra/.zvm/bin/zig build && /Users/agra/.zvm/bin/zig
|
||||
> build test && bash tests/run_examples.sh` must stay 361/0 with the FFI examples
|
||||
> green (the happy path is unchanged); (2) add a `*.test.zig` unit test that
|
||||
> constructs an FFI call op with a bogus arg ref and asserts the loud failure fires
|
||||
> (not a `.void` silent default). Expected new behavior: an unresolved FFI arg type
|
||||
> produces a clear compiler error / panic, never a void-typed foreign argument.
|
||||
Reference in New Issue
Block a user