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).
7.7 KiB
FIXED. Root cause was NOT the parameter name; the original this
rename surfaced an unrelated target_type leak in
resolveCallParamTypes. See "Root cause + fix" below.
Symptom
In an #objc_class method body, the first *Self parameter MUST be
named self. Renaming to anything else (e.g. this) compiles cleanly
but produces wrong code at runtime: reading the parameter back (e.g.
xx this to coerce to *void) yields a small struct-offset-shaped
value (saw 0x20 / 32 in our repro) instead of the Obj-C id that the
IMP trampoline received. Calling into the Obj-C runtime with that value
crashes with EXC_BAD_ACCESS / SIGSEGV at a near-null address.
Observed:
library/modules/platform/uikit.sxSxAppDelegate methods renamedself→this. Body calledcenter.addObserver_selector_name_object(xx this, ...).- Chess on iOS-sim crashed at first launch in
-[NSNotificationCenter addObserver:selector:name:object:]→object_getClass(observer), withobserver = 32(low-int, not a real pointer). - Reverting
this→self(viased) fixed the crash. Same body shape, samexx self→ call works fine.
Expected: parameter name should not matter. The IMP trampoline binds
the receiver id to whatever the first parameter is named in the
method declaration — by position, not by hardcoded name.
Reproduction
#import "modules/std.sx";
#import "modules/std/objc.sx";
// Extern declaration so we can dispatch.
NSObject :: #objc_class("NSObject") extern {
class :: () -> *void;
description :: (self: *Self) -> *void;
}
// sx-defined class whose method's *Self param is named `this`.
Foo :: #objc_class("SxFooSelfTest") {
#extends NSObject;
poke :: (this: *Self) -> *void {
// Should return the receiver back to the caller.
return xx this;
}
}
main :: () -> i32 {
inline if OS == .macos {
f := Foo.alloc().init();
result := f.poke();
// result should be the same pointer as `f`. Expect equal.
if xx result == xx f {
print("ok\n");
} else {
print("WRONG: this != self\n");
// result will be a struct-offset shaped value
// (e.g. 0x20) instead of the Obj-C id.
}
}
inline if OS != .macos { print("skipped (not macos)\n"); }
0;
}
Build and run on macOS; expected ok; observed WRONG: this != self
(or a crash if the value is dereferenced).
If you swap this → self everywhere in the body and parameter list,
the test prints ok.
Investigation prompt
In src/ir/lower.zig, several IMP-trampoline / method-body emission
paths hardcode the string "self" when binding the receiver parameter
or looking it up in the scope. Grep hits at the time of filing:
$ grep -n '"self"' src/ir/lower.zig
3422: init_scope.put("self", .{ ... });
5133: const self_binding = if (self.scope) |s| s.lookup("self") else null;
10044: .name = "self",
11999/12056/12499/12662: params.append(... .internString("self") ...);
The methods that synthesize the IMP trampoline (M1.2 A.2) and the
ones that wire *Self → opaque runtime-class stub (M1.2 A.3) appear
to either:
(a) emit the trampoline assuming the slot name in the body is "self",
so a body declared with this: *Self reads from an uninitialized /
different slot when it accesses the parameter; OR
(b) resolve *Self-typed parameters by name rather than by position
+ type, so a non-self name routes through a slower / different
binding path that doesn't see the IMP-passed receiver.
Likely fix: have the parser / type-checker for #objc_class method
bodies identify the first *Self-typed parameter by position and
type, and bind the IMP-passed receiver into whatever local name the
user chose. Remove hardcoded "self" literals from the trampoline
emission and from the M1.2 A.3 lowerFieldAccess / lowerAssignment
helpers (the ivar→struct_gep path needs to know which local IS the
receiver, not assume it's named "self").
Verification step:
- Apply the fix.
- Save the repro above as
examples/issue-0044.sx. - Run
./zig-out/bin/sx run examples/issue-0044.sx— expectok(notWRONG: ...and not a crash). - Bonus: confirm
bash tests/run_examples.shstill passes (no regression in existingffi-objc-*tests, which all happen to useselfand so wouldn't have caught this).
Background
Encountered during the FFI M3 follow-up cleanup of
library/modules/platform/uikit.sx. Renamed the IMP-side first
parameter from self to this across all #objc_class methods to
free self for upcoming M4 self.method() UFCS work on
UIKitPlatform methods called from those class bodies. Crash
manifested at first [notificationCenter addObserver:self ...] in
-application:didFinishLaunchingWithOptions:. Workaround in-session
was sed -i '' 's/this: \*Self/self: *Self/g; s/xx this/xx self/g'
across uikit.sx — the rename was uniform so the substitution was safe.
Cost of the foot-gun: future contributors who follow CLAUDE.md's
"any first parameter name that makes the body clearer is fine" mental
model will silently mis-compile their #objc_class method bodies.
Root cause + fix
The parameter name this vs self was a red herring. What actually
went wrong:
- uikit.sx renamed the AppDelegate's IMP method first param to
this, soxx thisappeared inside the body of a-> BOOLmethod. - The body called
center.addObserver_selector_name_object(xx this, ..., null)on a*NSNotificationCenterruntime-class receiver. lowerCallsets a per-argself.target_typefromresolveCallParamTypes(c). For UFCS dispatch on a runtime-class alias, that function had no path coveringruntime_class_map— it triedresolveFuncByName(qualified)andfn_ast_map.get(qualified), both of which miss for#objc_class(…) externmethods.- With
param_typesempty, the per-argtarget_typeassignment was skipped, soself.target_typeretained its previous value: the enclosing fn's return type, BOOL → i8. xx thisthen lowered with target typei8:ptrtoint ptr to i64→trunc i64 to i8. The receiver pointer became its low byte (0xC0 / 0x20 / etc., depending on heap address).addObserver:selector:name:object:got that byte as the observer. Apple's runtime callsobject_getClass(observer)internally for validation → near-null deref → SIGSEGV.
The same shape works fine in sx run because the xx cast wasn't
exercised in the body in the original tests, OR the encoding
happened to land somewhere benign (e.g. the AOT-with-iOS-sim path
plus UIKit's specific validation order).
Fix: add a runtime_class_map.get(sname) →
findRuntimeMethodInChain path to resolveCallParamTypes. When the
UFCS receiver is a runtime-class alias, walk the #extends chain to
find the method, then resolve its declared param types (skipping the
implicit *Self for instance methods). With the fix, param_types
returns [*void, *void, *void, *void] for the addObserver: call,
each xx ptr gets target type *void, and the cast is a clean
ptrtoint → inttoptr round-trip (or no-op since both sides are
pointer-typed).
The parameter-name hardcoding in lower.zig (lines 3422, 5133, 10044,
11999, 12056, 12499, 12662) is unrelated — those are all SYNTHESIZED
parameters in compiler-generated functions (init scopes, JNI stubs,
property IMPs, dealloc IMPs), not the user-facing #objc_class
method body. The user's first param can be named anything.
Regression test: examples/issue-0044.sx. Pre-fix, the
captureSelf-from-BOOL probe prints WRONG because xx this gets
truncated to its low byte and the round-trip comparison fails. With
the fix, all three probes print ok.