Skip to content

fix: release what calls from Go autorelease - #21

Open
acouvreur wants to merge 2 commits into
masterfrom
autorelease-pools
Open

acouvreur wants to merge 2 commits into
masterfrom
autorelease-pools

Conversation

@acouvreur

@acouvreur acouvreur commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

A Go thread has no autorelease pool, so an object autoreleased by a call from Go stays allocated until its thread exits, which Go threads rarely do. Every characteristic write kept its NSData: 280 bytes for a 244-byte write.

Every function that calls into Objective-C now starts with defer popPool(pushPool()): an autorelease pool, pushed on a locked thread and drained when the function returns, after it has copied any C string or bytes that point into the released objects.

btleplug fixed the same leak in its CoreBluetooth backend: deviceplug/btleplug#494 reported memory growing without bound while scanning, and deviceplug/btleplug@0250198 wraps each message on its CoreBluetooth thread in objc_autoreleasePoolPush/Pop. cbgo has no thread of its own for calls from Go, so the pool goes around each call instead.

Draining exposed what the leak hid:

  • Nine arrays and buffers were autoreleased twice (scan, advertising, updateValue, and the service and characteristic setters), which over-releases them once a pool drains.
  • str_to_nsstring and str_to_nsuuid returned objects that nothing released. They now return them autoreleased, like the other helpers.
  • The peripherals the retrieve calls return were kept alive only by the leaked array. They are now retained, like the ones the delegate hands to Go.

The tests measure the C heap over 10000 calls, with nil receivers where a call would need a Bluetooth controller, and check that every function calling into Objective-C starts with the pool. CI now runs them.

Tested on macOS 15.8.1 against an ESP32-S3 peripheral through tinygo-org/bluetooth v0.16.0: connect by identifier, a read of every service, and seven complete uploads of 404 to 461 KB. go build, go vet and go test pass, and the package builds for ios/arm64.

🤖 Generated with Claude Code

A Go thread has no autorelease pool, so an object autoreleased by a call
from Go stayed allocated until its thread exited, which Go threads rarely
do. Every characteristic write kept its NSData: 280 bytes for a 244-byte
write, so a 2 MB firmware upload kept more than 2 MB.

Every function that calls into Objective-C now starts with
defer popPool(pushPool()): an autorelease pool, pushed on a locked thread
and drained when the function returns, after it has copied any C string
or bytes that point into the released objects.

Draining exposed what the leak hid:

- Nine arrays and buffers were autoreleased twice (scan, advertising,
  updateValue, and the service and characteristic setters), which
  over-releases them once a pool drains.
- str_to_nsstring and str_to_nsuuid returned objects that nothing
  released; they now return them autoreleased, like the other helpers.
- The peripherals the retrieve calls return were kept alive only by the
  leaked array; they are now retained, like the ones the delegate hands
  to Go.

The tests check the C heap after 10000 calls, with nil receivers where a
call would need a Bluetooth controller, and check that every function
calling into Objective-C starts with the pool. CI now runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On arm64 macOS the allocator's in-use count moves in 16 KiB steps now and
then, so a single run of 10000 calls could leave 16 KiB with nothing
leaked, and CI failed at random. A leaking call leaves memory in every
batch of calls, so the tests now run five batches of 2000 and fail only
when every batch left more than 1 byte per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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