Skip to content

DispatchMain gives the block back - #16

Merged
tannevaled merged 1 commit into
mainfrom
dispatchmain-gives-the-block-back
Sep 8, 2026
Merged

tannevaled merged 1 commit into
mainfrom
dispatchmain-gives-the-block-back

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Every call leaked one Objective-C block and one entry in purego's block table — the entry that holds the Go closure alive — for the life of the process. Nothing ever removed either: purego's dispose helper is what deletes a table entry, and dispose only runs when the last reference to the block goes.

Measured

One process posting blocks and draining them on the main thread:

blocks posted before after
~3 500 14.2 MB 13.5 MB
~40 000 22.3 MB 17.8 MB
~160 000 44.4 MB 19.7 MB

Growth over the same span falls from +30.2 MB to +6.2 MB — about 200 bytes a call, previously never returned. An application that hops to the main thread once a frame reached the old numbers in minutes.

Releasing here is safe because dispatch_async takes its own reference, and must: its contract is that the caller may free the block as soon as the call returns.

The test's seam had to be completed, not just used

The scheduled-hop test fakes mkBlock with a sentinel. Leaving the real rmBlock in place sent 0xB10C to _Block_release and the suite died with fault 0xb114. A seam that is only half replaced runs half the real thing.

⚠ Not a fix for the crash this was found while chasing

go-xrkit/desk's suite faults inside cfRunLoopRunInMode at iokit hid_darwin.go:434, invoking a block that belongs to this package. With this change it still does — 2 runs in 10, against 4 in 10 before, which is one small sample inside another's noise. That is a separate defect and is filed as one.

🤖 Generated with Claude Code

Every call leaked one Objective-C block and one entry in purego's block
table -- the entry that holds the Go closure alive -- for the life of the
process. Nothing ever removed either: purego's dispose helper is what
deletes a table entry, and dispose only runs when the last reference to
the block goes.

Measured, one process posting blocks and draining them on the main thread:

    3 576 blocks    14.2 MB resident
   42 824 blocks    22.3 MB
  161 782 blocks    44.4 MB

about 200 bytes a call, never returned. After:

    3 296 blocks    13.5 MB
   39 328 blocks    17.8 MB
  157 096 blocks    19.7 MB

Growth over the same span falls from +30.2 MB to +6.2 MB. An application
that hops to the main thread once a frame reached the old numbers in
minutes.

Releasing here is safe because dispatch_async takes its own reference, and
must: its contract is that the caller may free the block as soon as the
call returns.

⛔ THE TEST'S SEAM HAD TO BE COMPLETED, NOT JUST USED. The scheduled-hop
test fakes mkBlock with a sentinel; leaving the real rmBlock in place sent
0xB10C to _Block_release and the suite died with "fault 0xb114". A seam
that is only half replaced runs half the real thing.

⚠ NOT a fix for the crash this was found while chasing. go-xrkit/desk's
suite faults inside cfRunLoopRunInMode at iokit hid_darwin.go:434,
invoking a block that belongs here; with this change it still does, 2 runs
in 10 against 4 in 10 before, which is one sample inside another's noise.
That is a separate defect and is filed as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 694556d into main Sep 8, 2026
3 checks passed
@tannevaled
tannevaled deleted the dispatchmain-gives-the-block-back branch September 8, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant