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:
agra
2026-06-03 15:43:27 +03:00
parent 6f4b872254
commit 4537538bb2
4 changed files with 145 additions and 4 deletions

View 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.