Skip to content

Make helper-thread spawn failures non-fatal#311

Open
robobun wants to merge 1 commit into
mainfrom
claude/thread-create-fallible
Open

Make helper-thread spawn failures non-fatal#311
robobun wants to merge 1 commit into
mainfrom
claude/thread-create-fallible

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Add WTF::Thread::tryCreate (returns nullptr instead of RELEASE_ASSERT when pthread_create/_beginthreadex is refused) and route the process-survivable callers through it.

Motivation

A Bun app that churns Workers inside a container with a pids.max cgroup budget (or a user at RLIMIT_NPROC) can hit pthread_create EAGAIN on any of JSC's lazily-spawned helper threads. Every one of those paths currently RELEASE_ASSERTs or PAS_ASSERTs, so the whole process is killed with all its live requests instead of just that one Worker failing. Node at the same limit fails the Worker and recovers.

Repro (as any non-root user on Linux):

bash -c 'ulimit -u 16 && exec bun worker-churn.mjs'

with worker-churn.mjs creating and terminating a few Workers in a loop: SIGABRT in WTF::Thread::create / pas_scavenger_notify_eligibility_if_needed.

Changes

  • WTF::Thread::tryCreate: new fallible variant. Thread::create now delegates to it and keeps the RELEASE_ASSERT (with the thread name in the message) for callers that cannot tolerate failure.
  • AutomaticThread::start: use tryCreate; on failure stay in the no-underlying-thread state so the next notifyOne/notifyAll retries. Covers the GC marker ParallelHelperPool, the JIT/Wasm worklists, and the per-VM Heap::HeapThread.
  • Heap::finishRelinquishingConn: if the collector thread has no underlying thread after notifyOne, re-set mutatorHasConnBit so the mutator drives the collection instead of parking forever in waitForCollector.
  • pas_scavenger_notify_eligibility_if_needed: on pthread_create failure, drop back to pas_scavenger_state_no_thread instead of PAS_ASSERTing; the next did_create_eligible retries.
  • VMTraps::initializeSignals: create the signal-sender WorkQueue eagerly so its backing thread is spawned while there is still thread budget rather than on the first fireTrap (which arrives after Workers have consumed every slot; RunLoop::create cannot recover).

With these, a process at a thread budget degrades (slower GC, deferred JIT, fewer marking helpers) instead of aborting. Verified on the bun side across RLIMIT_NPROC 13..28: every value now exits cleanly where it previously SIGABRTed.

Companion regression test lives in oven-sh/bun; this PR is the load-bearing half.

Add WTF::Thread::tryCreate (returns nullptr instead of RELEASE_ASSERT
when pthread_create/_beginthreadex is refused) and route the
process-survivable callers through it:

  - AutomaticThread::start: on failure, stay in the no-underlying-thread
    state so the next notify retries. Covers the GC marker pool
    (ParallelHelperPool), the JIT/Wasm worklists, and the per-VM Heap
    collector thread.
  - Heap::finishRelinquishingConn: if the collector thread could not be
    spawned, take the conn back so the mutator drives the collection
    instead of parking forever in waitForCollector.
  - pas_scavenger: on pthread_create failure, drop back to the
    no-thread state and let the next eligibility notification retry
    instead of PAS_ASSERTing.
  - VMTraps: create the signal-sender WorkQueue eagerly in
    initializeSignals so it is spawned while the process still has
    thread budget rather than on the first fireTrap.

A container that caps pids.max (or a user at RLIMIT_NPROC) can hit
pthread_create EAGAIN on any of these when an embedder churns VMs; the
process now degrades (slower GC/JIT, fewer helpers) instead of
aborting.
@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Companion bun-side regression test: oven-sh/bun#34705

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The pull request adds nullable thread creation handling in WTF and updates AutomaticThread, VMTraps, JavaScriptCore heap collection, and pas scavenging paths to handle thread creation failures without unconditional termination.

Thread creation resilience

Layer / File(s) Summary
Nullable thread creation API
Source/WTF/wtf/Threading.h, Source/WTF/wtf/Threading.cpp
Adds Thread::tryCreate, makes Thread::create delegate to it with an assertion, and cleans up context references when handle establishment fails.
Thread-backed runtime paths
Source/WTF/wtf/AutomaticThread.cpp, Source/JavaScriptCore/runtime/VMTraps.cpp
Allows AutomaticThread to retain a retryable no-thread state and initializes the VMTraps signal-sender queue during signal setup.
Subsystem failure recovery
Source/JavaScriptCore/heap/Heap.cpp, Source/bmalloc/libpas/src/libpas/pas_scavenger.c
Restores mutator-driven collection when the collector thread is unavailable and resets pas scavenger state after pthread_create failure.

Suggested reviewers: constellation, cdumez, jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it doesn't follow the required WebKit template and omits the bug link, Reviewed by line, and file-by-file commit format. Rewrite it in the repository template: include the Bugzilla link, Reviewed by NOBODY, fix explanation, and file/function bullets.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: making helper-thread spawn failures non-fatal.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/heap/Heap.cpp`:
- Around line 2262-2267: Update the thread-creation failure fallback in
releaseAccessSlow() to restore both mutatorHasConnBit and the stoppedBit set
during finishRelinquishingConn(), before the mutator can attempt to acquire
access. Ensure the resulting world state allows acquireAccessSlow() to proceed
rather than park indefinitely, while preserving the existing connection-reclaim
behavior.

In `@Source/JavaScriptCore/runtime/VMTraps.cpp`:
- Around line 369-374: Update the VM trap queue initialization around queue() so
Thread::create failures are handled without aborting. Make the trap-queue
creation API fallible and propagate an initialization failure, or provide a safe
fallback before VM setup completes; ensure later fireTrap() paths handle an
unavailable queue without terminating the process.

In `@Source/WTF/wtf/Threading.cpp`:
- Line 338: Correct the comment near Thread::entryPoint to state that the local
Ref context releases the remaining reference when the scope exits, rather than
attributing that cleanup to MutexLocker locker. Keep the comment focused on
undoing the speculative +1.

In `@Source/WTF/wtf/Threading.h`:
- Around line 141-142: Update the ownership comment for the thread-creation API
near NewThreadContext/tryCreate to state that entryPoint is consumed regardless
of success: on failure, the moved Function is destroyed and its captures
released, so the caller must not reuse or reclaim the closure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f27314f7-d275-48bc-9237-8d61cc238678

📥 Commits

Reviewing files that changed from the base of the PR and between a0e65bf and 155b04c.

📒 Files selected for processing (6)
  • Source/JavaScriptCore/heap/Heap.cpp
  • Source/JavaScriptCore/runtime/VMTraps.cpp
  • Source/WTF/wtf/AutomaticThread.cpp
  • Source/WTF/wtf/Threading.cpp
  • Source/WTF/wtf/Threading.h
  • Source/bmalloc/libpas/src/libpas/pas_scavenger.c

Comment on lines +2262 to +2267
if (!m_thread->hasUnderlyingThread(locker)) [[unlikely]] {
// The collector thread couldn't spawn (pthread_create EAGAIN at a
// pids/thread limit). Take the conn back so the mutator drives the
// collection; otherwise waitForCollector() would park forever.
m_worldState.exchangeOr(mutatorHasConnBit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Clear the stopped state when reclaiming the connection.

releaseAccessSlow() sets stoppedBit at Lines [2218-2225] before calling finishRelinquishingConn(). On thread-creation failure, this code restores only mutatorHasConnBit; acquireAccessSlow() checks stoppedBit first and parks, leaving no collector thread available to clear it. The fallback can therefore deadlock the mutator. Restore the connection and undo the stop state established by that handoff before unparking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/heap/Heap.cpp` around lines 2262 - 2267, Update the
thread-creation failure fallback in releaseAccessSlow() to restore both
mutatorHasConnBit and the stoppedBit set during finishRelinquishingConn(),
before the mutator can attempt to acquire access. Ensure the resulting world
state allows acquireAccessSlow() to proceed rather than park indefinitely, while
preserving the existing connection-reclaim behavior.

Comment on lines +369 to +374
// Create the signal-sender WorkQueue (and its backing thread) now,
// while the process still has thread-budget headroom, instead of on
// the first fireTrap() which may arrive after workers have consumed
// every slot of a cgroup pids.max budget. RunLoop::create() cannot
// recover from a failed Thread::create.
queue();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'WorkQueue::create|RunLoop::create|Thread::create' Source/WTF Source/JavaScriptCore

Repository: oven-sh/WebKit

Length of output: 6531


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== VMTraps.cpp excerpt ==\n'
sed -n '330,390p' Source/JavaScriptCore/runtime/VMTraps.cpp

printf '\n== WorkQueue.cpp excerpt ==\n'
sed -n '1,240p' Source/WTF/wtf/WorkQueue.cpp

printf '\n== generic WorkQueue implementation ==\n'
sed -n '1,140p' Source/WTF/wtf/generic/WorkQueueGeneric.cpp

printf '\n== RunLoop.cpp excerpt ==\n'
sed -n '1,170p' Source/WTF/wtf/RunLoop.cpp

printf '\n== Threading.cpp create signature area ==\n'
sed -n '300,380p' Source/WTF/wtf/Threading.cpp

Repository: oven-sh/WebKit

Length of output: 18641


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for p in [
    Path("Source/JavaScriptCore/runtime/VMTraps.cpp"),
    Path("Source/WTF/wtf/WorkQueue.cpp"),
    Path("Source/WTF/wtf/generic/WorkQueueGeneric.cpp"),
    Path("Source/WTF/wtf/RunLoop.cpp"),
]:
    text = p.read_text()
    print(f"\n== {p} contains create paths ==")
    for i, line in enumerate(text.splitlines(), 1):
        if "create(" in line or "Thread::create" in line or "RunLoop::create" in line or "WorkQueue::create" in line:
            print(f"{i}: {line}")
PY

Repository: oven-sh/WebKit

Length of output: 1197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== WorkQueue / RunLoop fallible APIs ==\n'
rg -n '\btryCreate\b|Failed to create thread|RELEASE_ASSERT_WITH_MESSAGE\(thread' Source/WTF Source/JavaScriptCore

printf '\n== VMTraps uses queue() or signal sender ==\n'
rg -n 'VMTraps::queue|SignalSender|fireTrap\(' Source/JavaScriptCore/runtime/VMTraps.cpp Source/JavaScriptCore/runtime

Repository: oven-sh/WebKit

Length of output: 48829


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'initializeSignals\(' Source/JavaScriptCore
sed -n '660,760p' Source/JavaScriptCore/runtime/VM.cpp
sed -n '1,220p' Source/JavaScriptCore/runtime/VM.h

Repository: oven-sh/WebKit

Length of output: 10152


Make VM trap queue creation fallible.

queue() still hard-aborts if Thread::create fails under pids.max/RLIMIT_NPROC exhaustion. Creating it eagerly here only moves that failure earlier; consider a fallible trap-queue API or a safe fallback before initialization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/VMTraps.cpp` around lines 369 - 374, Update the
VM trap queue initialization around queue() so Thread::create failures are
handled without aborting. Make the trap-queue creation API fallible and
propagate an initialization failure, or provide a safe fallback before VM setup
completes; ensure later fireTrap() paths handle an unavailable queue without
terminating the process.

bool success = thread->establishHandle(context.get(), stackSpec, qos, schedulingPolicy);
RELEASE_ASSERT(success);
if (!thread->establishHandle(context.get(), stackSpec, qos, schedulingPolicy)) {
// Undo the speculative +1 for Thread::entryPoint; the locker's Ref drops the rest.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the refcount cleanup comment.

MutexLocker locker does not own a Ref; the local Ref context drops the remaining reference when the scope exits.

Suggested wording
-            // Undo the speculative +1 for Thread::entryPoint; the locker's Ref drops the rest.
+            // Undo the speculative +1 for Thread::entryPoint; the local Ref drops the rest.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Undo the speculative +1 for Thread::entryPoint; the locker's Ref drops the rest.
// Undo the speculative +1 for Thread::entryPoint; the local Ref drops the rest.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/WTF/wtf/Threading.cpp` at line 338, Correct the comment near
Thread::entryPoint to state that the local Ref context releases the remaining
reference when the scope exits, rather than attributing that cleanup to
MutexLocker locker. Keep the comment focused on undoing the speculative +1.

Comment on lines +141 to +142
// Returns nullptr if thread creation failed. The caller still owns entryPoint
// resources in that case (the moved Function is destroyed, its captures drop).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clarify ownership after failed thread creation.

entryPoint is moved into NewThreadContext; on failure, tryCreate destroys that Function and releases its captures. The caller cannot still own or reuse the moved closure, so document that it is consumed even on failure.

Suggested wording
-    // Returns nullptr if thread creation failed. The caller still owns entryPoint
-    // resources in that case (the moved Function is destroyed, its captures drop).
+    // Returns nullptr if thread creation failed. The entryPoint is consumed even
+    // on failure; the moved Function and its captures are destroyed.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Returns nullptr if thread creation failed. The caller still owns entryPoint
// resources in that case (the moved Function is destroyed, its captures drop).
// Returns nullptr if thread creation failed. The entryPoint is consumed even
// on failure; the moved Function and its captures are destroyed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/WTF/wtf/Threading.h` around lines 141 - 142, Update the ownership
comment for the thread-creation API near NewThreadContext/tryCreate to state
that entryPoint is consumed regardless of success: on failure, the moved
Function is destroyed and its captures released, so the caller must not reuse or
reclaim the closure.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
155b04c1 autobuild-preview-pr-311-155b04c1 2026-07-19 13:49:29 UTC

Comment on lines +249 to +255
if (!thread) {
// pthread_create/_beginthreadex refused (e.g. EAGAIN at a thread/pids
// limit). Stay in the no-underlying-thread state so the next notify
// retries instead of bringing the whole process down.
m_hasUnderlyingThread = false;
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Swallowing the spawn failure here can hang JITWorklist::waitUntilAllPlansForVMAreReady forever. When all JITWorklistThreads have timed out (default 10 s idle) and start() fails at the pids limit, wakeThreads() still increments m_numberOfActiveThreads and leaves the plan at Preparing with no thread to advance it, so any later Heap::completeAllJITPlans() (via deleteAllCodeBlocks, forEachCodeBlockImpl, or the Debugger:: paths) parks indefinitely on m_planCompiledOrCancelled. The PR added a hasUnderlyingThread fallback for the collector in finishRelinquishingConn; JITWorklist::wakeThreads needs the same treatment (fall through to the synchronous !useConcurrentJIT compile-in-caller path, or drop the plan).

Extended reasoning...

What the bug is

The PR's premise for AutomaticThread::start() is that on tryCreate failure it can "stay in the no-underlying-thread state so the next notify retries" and JIT is merely "deferred". That is only safe if every caller that later waits for the AutomaticThread's work is prepared for the work never happening. JITWorklist is not: it has a blocking wait (waitUntilAllPlansForVMAreReady) that assumes a compiler thread will eventually move every enqueued plan to Ready.

Code path

JITWorklistThread is constructed with the default AutomaticThread timeout (JITWorklistThread.cpp:81 passes only (locker, lock, cond, ThreadType::Compiler) — no timeout override, so 10_s from AutomaticThread.h). After 10 s idle the underlying OS thread exits via stopForTimeout, leaving m_hasUnderlyingThread == false. This is a normal steady state in any long-running process with a quiet period; it also holds for the very first JIT compilation.

When a plan is enqueued in that state, JITWorklist::enqueue() adds it to m_plans / m_queues[tier] at stage Preparing and calls wakeThreads() (JITWorklist.cpp:175-179). wakeThreads (JITWorklist.cpp:144-147) does:

while (m_numberOfActiveThreads < targetNumThreads) {
    m_planEnqueued->notifyOne(locker);
    m_numberOfActiveThreads++;
}

AutomaticThreadCondition::notifyOne finds a thread with !hasUnderlyingThread and calls start(). With this PR, start() sets m_hasUnderlyingThread = false and returns silently when Thread::tryCreate fails. wakeThreads still increments m_numberOfActiveThreads. That counter is only decremented in JITWorklistThread::poll() (JITWorklistThread.cpp:122-123), which requires a running OS thread — so the count is now permanently inflated, and later enqueues may skip the notify entirely because m_numberOfActiveThreads >= targetNumThreads.

Why nothing rescues it

The plan sits in m_plans at stage Preparing with nobody to advance it. waitUntilAllPlansForVMAreReady (JITWorklist.cpp:286-301) loops while any plan for this VM has stage != Ready and blocks on m_planCompiledOrCancelled.wait(*m_lock). That condition is notified only by a compiler thread's work() completion (JITWorklistThread.cpp:167) or by removeDeadPlans (JITWorklist.cpp:478). Neither fires: there is no compiler thread, and even if a collector-driven removeDeadPlans woke the waiter, the plan is still Preparing for a live VM so the loop re-waits. cancelAllPlansForVM (VM shutdown) is safe because it removes stage-!=Compiling plans first; completeAllPlansForVM does not.

completeAllJITPlans is reachable via Heap::forEachCodeBlockImpl (Heap.cpp:2837), Heap::deleteAllCodeBlocks (Heap.cpp:1141), and four Debugger:: paths (setSteppingMode, toggleBreakpoint, clearBreakpoints, clearDebuggerRequests).

Impact

Before this PR the process would RELEASE_ASSERT inside Thread::create at step start() and be restarted by its supervisor. After this PR the same resource-exhaustion scenario the PR targets produces an indefinite hang holding all live requests — the exact outcome the PR is trying to avoid, but harder for a supervisor to detect than a SIGABRT. The PR description's "deferred JIT" claim does not hold once anything waits for JIT completion.

Step-by-step proof

  1. Bun process idles ≥10 s → all JIT worklist OS threads exit via stopForTimeout; every JITWorklistThread has m_hasUnderlyingThread == false.
  2. Workers churn until the process reaches pids.max / RLIMIT_NPROC.
  3. A function tiers up: JITWorklist::enqueue(plan)m_plans.add(key, plan) (stage Preparing) → wakeThreads().
  4. wakeThreadsnotifyOneAutomaticThread::startThread::tryCreate returns nullptr → new branch sets m_hasUnderlyingThread = false, returns. Back in wakeThreads, m_numberOfActiveThreads++.
  5. No thread ever runs poll()/work(); the plan stays Preparing.
  6. Something calls Heap::completeAllJITPlans() (e.g. Heap::deleteAllCodeBlocks on worker teardown, or a Debugger attach).
  7. completeAllPlansForVMwaitUntilAllPlansForVMAreReady finds the Preparing plan → m_planCompiledOrCancelled.wait(*m_lock) forever.

Fix

Mirror the collector-thread fallback the PR already added in finishRelinquishingConn: after notifyOne in JITWorklist::wakeThreads, check whether a thread with an underlying OS thread actually exists; if none does, either fall through to the existing synchronous compile path (the !Options::useConcurrentJIT() branch at the top of enqueue, JITWorklist.cpp:153-163) or drop the plan and return CompilationDeferred without adding it to m_plans. Alternatively, have AutomaticThread::start / notifyOne report failure so callers can react.

Comment on lines +2262 to +2267
if (!m_thread->hasUnderlyingThread(locker)) [[unlikely]] {
// The collector thread couldn't spawn (pthread_create EAGAIN at a
// pids/thread limit). Take the conn back so the mutator drives the
// collection; otherwise waitForCollector() would park forever.
m_worldState.exchangeOr(mutatorHasConnBit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Re-setting mutatorHasConnBit here breaks both callers when the collector fails to spawn. The no-arg relinquishConn() at line 2274 is while (relinquishConn(m_worldState.load())) { } — it CAS-clears the bit, calls finishRelinquishingConn() which re-sets it, returns true, and loops forever burning pthread_create syscalls (100 % CPU spin at exactly the pids limit this PR targets). And when reached from releaseAccessSlow() (line 2216-2229), stoppedBit has already been set and hasAccessBit cleared, so re-setting the conn produces stoppedBit | mutatorHasConnBit with no collector — acquireAccessSlow() then parks forever on stoppedBit. The fallback needs to signal spawn failure without re-setting the bit inside the loop (e.g. return a status from finishRelinquishingConn() and break out of relinquishConn(), and clear/skip stoppedBit on the release-access path).

Extended reasoning...

What the bug is

The new fallback in finishRelinquishingConn() does m_worldState.exchangeOr(mutatorHasConnBit) when the collector AutomaticThread has no underlying thread after notifyOne(). This function has two callers, and the fallback is incorrect for both.

Path 1: infinite spin in relinquishConn()

Heap::relinquishConn() (no-arg) at Heap.cpp:2272-2274 is:

while (relinquishConn(m_worldState.load())) { }

relinquishConn(oldState) (2235-2251) returns false only when !(oldState & mutatorHasConnBit) (line 2240) or m_threadShouldStop (line 2243). On a successful CAS it clears mutatorHasConnBit, calls finishRelinquishingConn(), and returns true — the loop is designed to terminate on the next iteration when the reload sees the bit clear.

Step-by-step under a thread/pids limit:

  1. Iteration 1 loads oldState with mutatorHasConnBit set.
  2. CAS at line 2246 clears the bit; finishRelinquishingConn() runs.
  3. m_requests is non-empty (only the un-spawnable collector drains it), so notifyOne()AutomaticThread::start()Thread::tryCreate()pthread_create returns EAGAINm_hasUnderlyingThread = false.
  4. !m_thread->hasUnderlyingThread(locker) is true → exchangeOr(mutatorHasConnBit) re-sets the bit.
  5. relinquishConn(oldState) returns true.
  6. Iteration 2 reloads m_worldState, sees mutatorHasConnBit set again → back to step 2. Forever.

m_threadShouldStop stays false (it's only set at heap teardown), so the loop never exits. This is called from waitForCollector() at line 2161 — exactly the path the new comment says it's protecting. A synchronous GC (collectSync()waitForCollection()waitForCollector()) in a process at its RLIMIT_NPROC/pids.max budget — the PR's motivating repro — now busy-spins on the mutator issuing pthread_create in a tight loop instead of driving the collection itself. This trades the old SIGABRT for a 100 % CPU hang, which defeats the PR's stated goal of graceful degradation.

Path 2: stoppedBit hang via releaseAccessSlow()

The second caller is releaseAccessSlow() (Heap.cpp:2216-2229). When (oldState & mutatorHasConnBit) && m_nextPhase != m_currentPhase, it CASes to a state that clears hasAccessBit | mutatorHasConnBit and sets stoppedBit (line 2224, with the comment "the collector will be awoken"), then calls finishRelinquishingConn(). If spawn fails, the fallback ORs mutatorHasConnBit back in, producing stoppedBit | mutatorHasConnBit with no hasAccessBit and no collector. acquireAccessSlow() (line 2179-2186) then parks forever on stoppedBit — nothing will ever clear it (only the collector's resumeTheMutator() does), and the mutator can't drive the collection because it has released access. This state also violates the invariant asserted at stopTheMutator() line 2006 (RELEASE_ASSERT(!(oldState & mutatorHasConnBit)) under stoppedBit), so if a collector does eventually spawn it will crash there.

This second path is narrower — it needs m_nextPhase != m_currentPhase with the mutator holding the conn, which arises when the collector's finishChangingPhase(Collector) handed off via stopTheMutator() and the underlying HeapThread then timed out (10 s idle) before the mutator released access. But it's the same class of hang the PR is trying to eliminate, and the fix touches the same lines.

Why existing code doesn't prevent it

Before this PR, AutomaticThread::start would RELEASE_ASSERT on spawn failure, so finishRelinquishingConn() never returned with !hasUnderlyingThread. The while-loop in relinquishConn() and the stoppedBit handshake in releaseAccessSlow() were both written under the assumption that notifyOne() guarantees a running collector.

Suggested fix

Have finishRelinquishingConn() return whether the collector actually has an underlying thread, and let each caller react: relinquishConn() breaks out of the while-loop with the conn kept (so waitForCollector()'s next stopIfNecessarySlow() drives collectInMutatorThread()), and releaseAccessSlow() either avoids setting stoppedBit when !hasUnderlyingThread or clears it in the fallback. Simply doing exchangeOr(mutatorHasConnBit) inside the shared helper cannot satisfy both call sites.

Comment on lines +337 to +341
if (!thread->establishHandle(context.get(), stackSpec, qos, schedulingPolicy)) {
// Undo the speculative +1 for Thread::entryPoint; the locker's Ref drops the rest.
context->deref();
return nullptr;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 On Windows, when _beginthreadex fails and tryCreate returns nullptr, the local Ref<Thread> destructs and Thread::~Thread() runs if (m_handle != INVALID_HANDLE_VALUE) CloseHandle(m_handle) — but m_handle has no default initializer and was never assigned (assignment happens in establishPlatformSpecificHandle, which is skipped on failure). Previously the RELEASE_ASSERT(success) aborted before ~Thread() could run; now the recovery path reads an indeterminate handle and can CloseHandle() an unrelated live handle. Give m_handle a default initializer (e.g. INVALID_HANDLE_VALUE on Windows) in Threading.h.

Extended reasoning...

What the bug is

Thread::m_handle is declared in Source/WTF/wtf/Threading.h as PlatformThreadHandle m_handle; with no default initializer, and the Thread(SchedulingPolicy, IsMain) constructor only initializes m_isRealtime and m_uid. On Windows, Thread::~Thread() (ThreadingWin.cpp:102-108) does:

if (m_handle != INVALID_HANDLE_VALUE)
    CloseHandle(m_handle);

When establishHandle() fails, m_handle has never been written, so the destructor reads an indeterminate value and — since INVALID_HANDLE_VALUE is (HANDLE)-1 — almost certainly passes the check and calls CloseHandle() on garbage.

Code path that triggers it

In the new Thread::tryCreate (Threading.cpp:337-341):

if (!thread->establishHandle(context.get(), stackSpec, qos, schedulingPolicy)) {
    context->deref();
    return nullptr;
}

On Windows, establishHandle (ThreadingWin.cpp:153-168) returns false at line 164 when _beginthreadex fails — before line 166's establishPlatformSpecificHandle(threadHandle, threadIdentifier), which is the only place m_handle is assigned for a newly created thread.

Step-by-step proof

  1. AutomaticThread::startThread::tryCreate(...).
  2. adoptRef(*new Thread(schedulingPolicy, IsMain::No)) constructs a Thread; the ctor sets only m_isRealtime and m_uid. m_handle is left indeterminate.
  3. NewThreadContext is created holding a second Ref<Thread> (refcount = 2), plus a speculative context->ref() for the entry point.
  4. _beginthreadex returns 0 (thread limit hit) → establishHandle returns false at line 164; establishPlatformSpecificHandle never runs.
  5. tryCreate does context->deref() (undo speculative ref) and return nullptr.
  6. Unwinding: Ref context destructs → NewThreadContext destructs → its Ref<Thread> drops (2→1). Then Ref thread destructs (1→0) → Thread::~Thread() runs.
  7. ~Thread() reads the never-initialized m_handle, compares against (HANDLE)-1, and calls CloseHandle(garbage).

Why existing code doesn't prevent it

Before this PR, step 5 was RELEASE_ASSERT(success) — the process aborted before the local Ref<Thread> could destruct, so ~Thread() never ran with an uninitialized m_handle. This PR removes that abort specifically to let the process survive, which for the first time makes ~Thread() reachable on a Thread whose handle was never established. POSIX is unaffected because Thread::~Thread() = default there (ThreadingPOSIX.cpp:83).

Impact

This is UB on the exact scenario the PR is designed to survive on Windows (_beginthreadex refusal at a thread limit — explicitly called out in the PR description). CloseHandle() on an arbitrary bit pattern can silently close an unrelated live handle (file, event, thread, socket) elsewhere in the process, turning graceful degradation into non-deterministic corruption. Even if the allocator happened to zero the memory, NULL != INVALID_HANDLE_VALUE, so CloseHandle(NULL) would still be called.

Fix

Give m_handle a default initializer in Threading.h, e.g.:

#if OS(WINDOWS)
    PlatformThreadHandle m_handle { INVALID_HANDLE_VALUE };
#else
    PlatformThreadHandle m_handle { };
#endif

or set m_handle = INVALID_HANDLE_VALUE in the Windows establishHandle failure branch before returning false.

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