From 545c9d52859e48f534e37d4f1d0dc81c1cfec72f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 5 Oct 2026 23:45:18 +0100 Subject: [PATCH] A gc shared library loads without starting the collector's markers under the loader lock 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 --- tslang/lib/AsyncGCThreadsCommon.inc | 33 ++++++++++++++-------- tslang/test/tester/foreign-thread-gc.cmake | 21 +++++++++++++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/tslang/lib/AsyncGCThreadsCommon.inc b/tslang/lib/AsyncGCThreadsCommon.inc index 8382fe20d..5164bbe45 100644 --- a/tslang/lib/AsyncGCThreadsCommon.inc +++ b/tslang/lib/AsyncGCThreadsCommon.inc @@ -51,6 +51,7 @@ struct CallingThread thread_local CallingThread callingThread; +std::once_flag collectorInitialized; std::once_flag threadsEnabled; } // namespace @@ -63,11 +64,18 @@ extern "C" void GC_enable_threads(); // registers the threads it makes, but a library's host makes its own. GC_init registered the one // it ran on; any other is registered on its first call. // -// GC_enable_threads runs once here whatever ran before: GC_register_my_thread refuses every -// thread until GC_allow_register_threads was called ("Threads explicit registering is not -// previously enabled"), and the collector may well be initialized already without it - it -// initializes itself on first use, so a library with no top-level code (no GC_init injected at -// load) can find it running. Calling it again where GC_init's injection already did is harmless. +// The collector may well be initialized already: it initializes itself on first use, so a library +// with no top-level code (no GC_init injected at load) can find it running. +// +// GC_enable_threads runs only for a thread that has to be registered: GC_register_my_thread +// refuses every thread until GC_allow_register_threads was called ("Threads explicit registering +// is not previously enabled"). A registered thread must not run it here. An exported function can +// be called while the library is being loaded - a static constructor of an exported class, or +// top-level code calling an export - and on Windows that is inside DllMain, under the loader lock. +// GC_allow_register_threads starts the parallel marker threads and waits for them to come up, and +// a new thread cannot start until the loader lock is released: the load never finished (the +// default library hung every JIT run). The thread a library is loaded on is the one GC_init +// registered, or one its host registered. extern "C" void __tslang_gc_enter() { auto &thread = callingThread; @@ -76,20 +84,23 @@ extern "C" void __tslang_gc_enter() return; } - std::call_once(threadsEnabled, [] { + thread.checked = true; + + std::call_once(collectorInitialized, [] { if (!GC_is_init_called()) { GC_init(); } - - GC_enable_threads(); }); - thread.checked = true; - if (!GC_thread_is_registered()) + if (GC_thread_is_registered()) { - thread.registeredHere = registerThisThread(); + return; } + + std::call_once(threadsEnabled, [] { GC_enable_threads(); }); + + thread.registeredHere = registerThisThread(); } // Called once from the entry point: the GC pass injects the call beside GC_init, so it happens diff --git a/tslang/test/tester/foreign-thread-gc.cmake b/tslang/test/tester/foreign-thread-gc.cmake index ab129e752..5743747ad 100644 --- a/tslang/test/tester/foreign-thread-gc.cmake +++ b/tslang/test/tester/foreign-thread-gc.cmake @@ -9,6 +9,13 @@ # thousand calls collect many times, on every thread. # # Also with no top-level code: then nothing ran GC_init at load, and the first call does. +# +# And with exported functions called while the library loads - a static constructor of an +# exported class, top-level code calling an export - which on Windows runs inside DllMain, under +# the loader lock. The first call there enabled threads, which started the collector's parallel +# marker threads and waited for them; they could not start until the load finished, and the load +# never did (the default library hung every JIT run). The allocations escape into globals, so the +# optimizer cannot fold the calls away. cmake_minimum_required(VERSION 3.17.3) @@ -48,6 +55,18 @@ export function work(seed: number): number { file(WRITE "${WORK_DIR}/with-top-level.ts" "${work}\nlet loaded: number = 1;\n") file(WRITE "${WORK_DIR}/no-top-level.ts" "${work}") +file(WRITE "${WORK_DIR}/static-constructor-at-load.ts" "${work} +export class Registry { + static first: Node = new Node(1, undefined); +} +") +file(WRITE "${WORK_DIR}/export-called-at-load.ts" "${work} +export function makeNode(value: number): Node { + return new Node(value, undefined); +} + +export let kept: Node = makeNode(1); +") set(libs "--gc-lib-path=${GC_LIB}" "--tslang-lib-path=${TSLANG_LIB}" "--llvm-lib-path=${LLVM_LIB}") if(DEFINED GC_SHARED_LIB) @@ -65,7 +84,7 @@ else() set(pic "-relocation-model=pic") endif() -foreach(name with-top-level no-top-level) +foreach(name with-top-level no-top-level static-constructor-at-load export-called-at-load) set(library "${WORK_DIR}/${prefix}${name}${suffix}") execute_process(COMMAND "${TSLANG}" --emit=dll --opt -mm=gc --no-default-lib ${pic} ${libs} ${name}.ts -o "${library}" WORKING_DIRECTORY "${WORK_DIR}"