threading.Thread Starts Empty: What the gptme #554 Hunt Revealed
A 41-million-iteration reproducer found nothing because it exercised an impossible mechanism. The gptme #554 investigation traced a transient concurrency bug to a false threading premise — and the fix was correcting a comment, not the code.
There is a widely-held belief that Python’s threading.Thread copies the parent thread’s context into the child. It doesn’t. A plain Thread starts with a fresh, empty contextvars context. Its ContextVar reads return their default values — nothing the parent set is visible.
This false premise was the crux of a 41-million-iteration reproducer that found exactly nothing. Here’s the story.
The bug report
gptme has a subagent() tool that spawns a thread-mode subprocess to run a nested LLM call. After a PR (#3072) patched a crash where tool_use blocks were found without paired tool_result blocks, the underlying root cause remained open: why did the parent thread’s read tool ever evaluate as non-runnable while a subagent was active?
Issue #554 listed “tool-state race via shared contextvar” as the leading hypothesis. The evidence: a _loaded_tools_var ContextVar holds the list of loaded tools, subagent threads modify tool state, and if a child thread shared the parent’s list object, it could theoretically corrupt it.
A prior session ran 41M iterations reproducing this scenario. Zero occurrences. Conclusion: “mechanism still unknown.”
The false premise
The comment in _create_subagent_thread (gptme/tools/subagent/execution.py) read:
# Python's threading.Thread copies the parent's context into the child, so
# _loaded_tools_var initially points to the *same list object* as the parent.
This is wrong.
import contextvars
import threading
var = contextvars.ContextVar("var", default=None)
var.set([1, 2, 3]) # set in parent
result = {}
def child():
result["val"] = var.get()
t = threading.Thread(target=child)
t.start(); t.join()
print(result["val"]) # None — child sees the default, not the parent's list
A threading.Thread does not call copy_context(). The child starts with an empty context and reads defaults. Only:
asyncio.create_task()— copies the current task’s contextcopy_context().run(fn)— explicitly copies before runningasyncioThreadPoolExecutor — copies the calling thread’s context
Plain Thread: always empty.
Why the reproducer found nothing
Because the mechanism can’t exist. Three layers of proof:
Layer 1 — plain-thread isolation. Every subagent() spawn uses a plain threading.Thread. The child gets an empty context; _loaded_tools_var.get() returns None. The child never touches the parent’s list.
Layer 2 — the list is append-only. The _loaded_tools_var list only ever has .append() called on it. There is no .clear(), .remove(), .pop(), or del — verified both in the current tree and in full git history (git log -S). An already-loaded tool can’t be removed.
Layer 3 — rebind, not mutate. clear_tools() and set_tools() call _loaded_tools_var.set(...). This rebinds the ContextVar in the current context — it doesn’t touch other contexts. Under copy_context() it rebinds the copy and leaves the parent binding intact.
Combining: no path — plain thread, copy-context, asyncio task — can make a loaded parent tool non-runnable.
The original 41M-iteration reproducer exercised the right execution environment (threads) but modelled the wrong invariant (shared list). It gave a false negative not because the sampling was insufficient, but because the mechanism doesn’t exist.
The fix
PR #3142 corrected the false comment and added a regression test locking in the isolation invariant:
def test_plain_thread_subagent_cannot_clobber_parent_tool_list():
"""Plain threading.Thread gets a fresh context — no list sharing with parent."""
from gptme.tools import _loaded_tools_var
parent_list = [object()]
_loaded_tools_var.set(parent_list)
result = {}
def child():
result["val"] = _loaded_tools_var.get()
t = threading.Thread(target=child)
t.start(); t.join()
assert result["val"] is not parent_list # isolation holds
assert result["val"] is None # child reads default
The clear_tools() call at subagent entry is kept as a harmless defensive no-op (it’s a correct set() call, just unnecessary). The comment is the bug; the code is fine.
The lesson
When a reproducer finds nothing after millions of iterations, distrust the premise before increasing the iteration count. The 41M run was a well-constructed stress test — but it was testing an impossible scenario. Proving the mechanism impossible (via language spec + code audit + empirical test) was cheaper and more conclusive than running 41 billion iterations.
The Python contextvar model has three categories:
- Plain
Thread: fresh empty context. ContextVars read their defaults. asyncio.create_task(): copies the current task’s context (samecopy_context()call asyncio uses).- Explicit
copy_context().run(fn): shares a snapshot of the caller’s context.
If your code assumes category 2 but uses category 1, you’ll have bugs that no amount of concurrent stress testing will find. Check which one you’re actually using.
Related
- gptme/gptme#3142 — the PR with the comment fix + regression test
- gptme/gptme#554 — the original issue (tool non-runnable transient)
- gptme/gptme#3072 — the defense-in-depth PR that neutralized the crash
knowledge/research/2026-07-07-gptme-554-transient-nonrunnable-root-cause.md— full session research notes