Skip to content

objc: SendSuper recursively reenters inherited overrides #533

Description

@hajimehoshi

PureGo Version

Reproduced with v0.11.0. Source inspection of main at 656af01 shows the same runtime-class-based dispatch in both ID.SendSuper and SendSuper[T]; main was not executed.

Operating System

  • Windows
  • macOS
  • Linux
  • FreeBSD
  • NetBSD
  • Android
  • iOS

macOS 26.5.1 (25F80), arm64.

Go Version (go version)

go version go1.27.0 darwin/arm64

What steps will reproduce the problem?

Register three classes: Base : NSObject, Override : Base, and Child : Override. Define a method on Base and override it on Override, calling self.SendSuper(cmd) from that override. Child inherits the override without adding any methods.

Calling the method on an Override instance works. Calling it on a Child instance recursively reenters Override's method.

Save the following as main.go in a module requiring github.com/ebitengine/purego v0.11.0, then run go run . on macOS. It uses Foundation only and opens no window. The recursion is capped at five calls to avoid a stack overflow. The third case checks explicit defining-class dispatch through objc_msgSendSuper2.

package main

import (
	"fmt"
	"structs"

	"github.com/ebitengine/purego"
	"github.com/ebitengine/purego/objc"
)

type objcSuper struct {
	_        structs.HostLayout
	receiver objc.ID
	class    objc.Class
}

func main() {
	lib, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
	if err != nil {
		panic(err)
	}
	var superSend func(*objcSuper, objc.SEL) objc.ID
	purego.RegisterLibFunc(&superSend, lib, "objc_msgSendSuper2")
	sel := objc.RegisterName("probe")
	var baseCalls, overrideCalls int
	var fixed bool
	base, err := objc.RegisterClass("Issue3740Base", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{
		{
			Cmd: sel,
			Fn:  func(self objc.ID, cmd objc.SEL) { baseCalls++ },
		},
	})
	if err != nil {
		panic(err)
	}
	var override objc.Class
	override, err = objc.RegisterClass("Issue3740Override", base, nil, nil, []objc.MethodDef{
		{
			Cmd: sel,
			Fn: func(self objc.ID, cmd objc.SEL) {
				overrideCalls++
				if overrideCalls >= 5 {
					return
				}
				if fixed {
					superSend(&objcSuper{
						receiver: self,
						class:    override,
					}, cmd)
				} else {
					self.SendSuper(cmd)
				}
			},
		},
	})
	if err != nil {
		panic(err)
	}
	child, err := objc.RegisterClass("Issue3740DynamicSubclass", override, nil, nil, nil)
	if err != nil {
		panic(err)
	}
	for _, tc := range []struct {
		name         string
		class        objc.Class
		fixed        bool
		wantBase     int
		wantOverride int
	}{
		{
			name:         "direct instance, existing SendSuper",
			class:        override,
			wantBase:     1,
			wantOverride: 1,
		},
		{
			name:         "subclass instance, existing SendSuper (guarded at 5)",
			class:        child,
			wantOverride: 5,
		},
		{
			name:         "subclass instance, lexical-class super dispatch",
			class:        child,
			fixed:        true,
			wantBase:     1,
			wantOverride: 1,
		},
	} {
		baseCalls, overrideCalls, fixed = 0, 0, tc.fixed
		obj := objc.ID(tc.class).Send(objc.RegisterName("new"))
		obj.Send(sel)
		obj.Send(objc.RegisterName("release"))
		fmt.Printf("%s: base=%d override=%d\n", tc.name, baseCalls, overrideCalls)
		if baseCalls != tc.wantBase || overrideCalls != tc.wantOverride {
			panic("unexpected dispatch counts")
		}
	}
}

What is the expected result?

A super call made from Override's method should reach Base's implementation once, including when the receiver is a Child instance. There should be an API that allows the caller to specify Override as the class defining the method, matching Objective-C super semantics.

What happens instead?

Observed output:

direct instance, existing SendSuper: base=1 override=1
subclass instance, existing SendSuper (guarded at 5): base=0 override=5
subclass instance, lexical-class super dispatch: base=1 override=1

For a Child instance, ID.SendSuper supplies id.Class() == Child to objc_msgSendSuper2. That function starts lookup in Child's superclass, Override, and finds the same override again. Without the guard, the method keeps recursively reentering itself.

Anything else you feel useful to add?

This differs from #509: passing the current class to objc_msgSendSuper2 is correct, and the direct-instance control above succeeds. The problem is using the receiver's runtime class instead of the class defining the executing method. Substituting id.Class().SuperClass() would break ordinary direct-instance calls by advancing an extra level.

An explicit defining-class parameter or a separate class-anchored super-send API would allow correct dispatch for inherited methods and dynamically subclassed receivers. Both the method and generic helpers currently use id.Class(); the executable reproduction above exercises the method helper and the raw explicit-class fix.

Discovered while investigating hajimehoshi/ebiten#3740. The connection to that application's crash remains unconfirmed: neither the reporter's dynamic receiver class nor a reproduction of the resizing trigger has been established locally. The class hierarchy above independently reproduces the purego API limitation.

Filed by Codex (OpenAI), on behalf of @hajimehoshi.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions