Files
sx/issues/0044-sx-defined-objc-class-method-self-param-must-be-named-self.md
agra a923b6f6f0 ffi fix: route foreign-class UFCS arg target_types through extends chain
For UFCS dispatch on foreign-class receivers (`#foreign #objc_class`
aliases), `resolveCallParamTypes` was returning an empty slice — both
`resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)` miss
for `#foreign` methods (they live in `foreign_class_map`, not the
regular fn maps). With `param_types` empty, the per-arg `target_type`
assignment in `lowerCall` was skipped, leaving `self.target_type` as
whatever it held on entry — usually the enclosing function's return
type. Inside a `-> BOOL` method, `xx ptr` then lowered with target
type `i8`: `ptrtoint ptr to i64` → `trunc i64 to i8`, sending the low
byte of the pointer through.

Symptom: chess on iOS-sim crashed in
`-[NSNotificationCenter addObserver:selector:name:object:]` with
`observer = 0xC0` (low byte of the SxAppDelegate receiver) when the
AppDelegate method's first param was renamed to anything other than
`self`. The original session diagnosed it as a `self`-vs-`this`
hardcoding in `lower.zig`, but those hardcoded `"self"` strings are
all on compiler-synthesized parameters (init scopes, JNI stubs,
property IMPs, dealloc IMPs) — not the user-facing #objc_class body
params. The bug was in arg-type resolution.

Fix walks `foreign_class_map` + `findForeignMethodInChain` to recover
the declared param types (skipping the implicit `*Self` for instance
methods). Regression test `examples/issue-0044.sx` exercises the
BOOL-return + foreign-class arg shape; pre-fix the receiver round-trip
prints WRONG, post-fix it prints ok.
2026-05-26 16:42:21 +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";

// Foreign declaration so we can dispatch.
NSObject :: #foreign #objc_class("NSObject") {
    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 :: () -> s32 {
    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 foreign-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 foreign-class receiver.
  3. lowerCall sets a per-arg self.target_type from resolveCallParamTypes(c). For UFCS dispatch on a foreign-class alias, that function had no path covering foreign_class_map — it tried resolveFuncByName(qualified) and fn_ast_map.get(qualified), both of which miss for #foreign #objc_class 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 foreign_class_map.get(sname)findForeignMethodInChain path to resolveCallParamTypes. When the UFCS receiver is a foreign-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.