Skip to content

A gc shared library registers the threads its host calls it on - #516

Merged
ASDAlexander77 merged 2 commits into
mainfrom
gc-foreign-threads
Oct 5, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
gc-foreign-threads

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Problem

Boehm scans, and stops during a collection, only the threads registered with it. A tslang program registers its own threads: GC_init registers the one it starts on, and the async runtime registers its pool's. But a host that isn't a tslang program calls a -mm=gc shared library on threads nobody registered. That covers an Android app's JNI threads and a plain C/C++ program's threads. The first collection one of those threads started aborted with Collecting from unknown thread. Reproduced on Windows and on an Android API 29 emulator. Only the loading thread worked.

Fix

  • __tslang_gc_enter (AsyncGCThreadsCommon.inc, in the async runtime's gc-only object):
    • checks the calling thread once, with a thread_local flag as the fast path;
    • registers the thread if the collector doesn't know it, and unregisters it at thread exit;
    • runs GC_enable_threads once. GC_register_my_thread refuses every thread until GC_allow_register_threads has run, and Boehm initializes itself on first use without it: a library with no top-level code had no GC_init injected at load. Without this, every foreign thread was silently refused (Threads explicit registering is not previously enabled), which was found on the emulator.
  • GCPass injects the call first thing in every exported function (passthrough export/dllexport) of a --emit=dll gc build. The global constructors are excluded, since GC_init there registers the loading thread, and internal functions are left alone (checked in the disassembly).
  • All OSes, not just Android: a Linux or Windows .so/.dll loaded by a non-tslang host had the same bug.

Tests

  • New test-compile-foreign-thread-gc. foreign-thread-gc-host (C++, not tslang) loads a gc library and calls it 2,000 times on the loading thread, then 2,000 times on each of 8 threads of its own, checking every result. It does this with and without top-level code in the library. It crashed before this change (0x80000003, Boehm's abort) and passes 20/20 after.
    • The files are named foreign-thread-gc* because the repo's .gitignore ignores gc-*.
  • Full Release suite on Windows: 3856/3856, including the 441 shared-library tests that now go through the entry hook.
  • Android emulator (API 29, x86_64): libraries allocating nodes, number arrays, string arrays and class instances run correctly from 8 foreign threads (dlopen host).

Known issue: not fixed here, and not in tslang

On the Android emulator, multi-threaded allocation of growing pointer-free blocks can hang Boehm 8.2.12. That's the pattern the default library's join (result += v) produces: the collector is stuck in GC_start_world → resend_lost_signals while one registered thread never leaves GC_suspend_handler's sigsuspend. It reproduces in pure C with no tslang code: Boehm linked into an executable hangs 3 runs in 8, inside a .so 5 in 8. It isn't parallel marking: GC_MARKERS=1 hangs too. Being investigated against newer bdwgc and reported upstream. Until then, -mm=rc is the safe model for Android app libraries.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 5, 2026 17:37
The collector scans, and stops during a collection, only the threads
registered with it. A tslang program registers its own (GC_init the one it
starts on, the async runtime its pool's), but a host that is not a tslang
program - an Android app's JNI threads, a C program's - calls a -mm=gc
shared library on threads nobody registered, and the first collection one
of them started aborted: "Collecting from unknown thread" (reproduced on
Windows and on an Android emulator).

- __tslang_gc_enter (AsyncGCThreadsCommon.inc): checks the calling thread
  once (thread_local), registers it if the collector does not know it, and
  unregisters it at thread exit. It also runs GC_enable_threads once:
  GC_register_my_thread refuses every thread until
  GC_allow_register_threads ran, and the collector can be initialized
  without it (it initializes itself on first use - a library with no
  top-level code has no GC_init injected at load).
- GCPass injects the call first thing in every exported function of a
  --emit=dll gc build (passthrough "export"/"dllexport"; not the global
  constructors, where GC_init registers the loading thread). Internal
  functions are left alone.

test-compile-foreign-thread-gc: foreign-thread-gc-host (C++, not tslang)
loads a gc library and calls it 2000 times on the loading thread and on
8 threads of its own, with and without top-level code. It crashed before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux a shared object's code has to be PIC; without -relocation-model=pic
the link failed with R_X86_64_32S (as defaultlib-collector.cmake already does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit d5f8195 into main Oct 5, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the gc-foreign-threads branch October 5, 2026 17:49
ASDAlexander77 added a commit that referenced this pull request Oct 5, 2026
…der the loader lock (#521)

Every JIT run against the default library hung at 0% CPU since #516, and the
release workflow's "Test Compiler with Default Library" step with it. #516
calls __tslang_gc_enter first in every exported function of a -mm=gc shared
library, and its first call ran GC_enable_threads. An exported function can
run while the library is being loaded - the default library's exported
Number.static_constructor is a global constructor, and top-level code can call
an export - and on Windows that is inside DllMain, under the loader lock.
GC_allow_register_threads starts the collector's parallel marker threads and
waits for them to come up; a new thread cannot start until the loader lock is
released, so LoadLibrary never returned. --emit=exe links the static default
library, which runs no DllMain, so only the DLL hung.

__tslang_gc_enter now enables threads only for a thread it has to register.
The thread a library loads on is the one GC_init registered, or one its host
registered, so the load no longer starts the markers.

test-compile-foreign-thread-gc: two more libraries, one whose exported class
has a static constructor and one whose top-level code calls an export, both
allocating into a global so the optimizer keeps the call. Both hung before
this change.

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