Files
sx/issues/0044-sx-defined-objc-class-method-self-param-must-be-named-self.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

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.sx SxAppDelegate methods renamed selfthis. Body called center.addObserver_selector_name_object(xx this, ...).
  • Chess on iOS-sim crashed at first launch in -[NSNotificationCenter addObserver:selector:name:object:]object_getClass(observer), with observer = 32 (low-int, not a real pointer).
  • Reverting thisself (via sed) fixed the crash. Same body shape, same xx 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 thisself 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:

  1. Apply the fix.
  2. Save the repro above as examples/issue-0044.sx.
  3. Run ./zig-out/bin/sx run examples/issue-0044.sx — expect ok (not WRONG: ... and not a crash).
  4. Bonus: confirm bash tests/run_examples.sh still passes (no regression in existing ffi-objc-* tests, which all happen to use self and 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:

  1. uikit.sx renamed the AppDelegate's IMP method first param to this, so xx this appeared inside the body of a -> BOOL method.
  2. The body called center.addObserver_selector_name_object(xx this, ..., null) on a *NSNotificationCenter runtime-class receiver.
  3. lowerCall sets a per-arg self.target_type from resolveCallParamTypes(c). For UFCS dispatch on a runtime-class alias, that function had no path covering runtime_class_map — it tried resolveFuncByName(qualified) and fn_ast_map.get(qualified), both of which miss for #objc_class(…) extern methods.
  4. With param_types empty, the per-arg target_type assignment was skipped, so self.target_type retained its previous value: the enclosing fn's return type, BOOL → i8.
  5. xx this then lowered with target type i8: ptrtoint ptr to i64trunc i64 to i8. The receiver pointer became its low byte (0xC0 / 0x20 / etc., depending on heap address).
  6. addObserver:selector:name:object: got that byte as the observer. Apple's runtime calls object_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 ptrtointinttoptr round-trip (or no-op since both sides are pointer-typed).

src/ir/lower.zig:8617-8639.

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.