fix(tokenizer): keep the tiktoken daemon thread out of the dynamic linker - #32
Merged
Merged
Conversation
…nker `get_encoding_nonblocking()` spawned a daemon thread whose first statement was `import tiktoken`. That import dlopens a Rust extension, and CPython kills daemon threads mid-flight at finalization (`pthread_exit` at the next GIL acquisition) — killed inside the dynamic linker it is not survivable: losing glibc's `_dl_load_lock` surfaces as a later SIGSEGV, unwinding through a Rust / `extern "C"` frame calls `abort()`. Both were observed in ApodexHarness as a signal death *after* a fully correct protocol stream (-11 and -6, two different tests). An all-thread dump at atexit showed the thread parked inside that import on *every* short run, not occasionally: the import takes 24-29 ms and the first `get_encoding_nonblocking()` call lands late in a run, so a short process reliably exits inside the window. Hoist the import to the calling thread — it is a local dlopen, no network, not what this module defends against — and leave only `get_encoding()` (140 ms+, unbounded on a cache miss) on the thread. Add an atexit join for an in-flight load, following the precedent in `providers/nonblocking_stream.py`. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
runtime/loop/tokenizer.pyspawned a daemon thread whose first statement wasimport tiktoken. This moves the import to the calling thread and leaves onlytiktoken.get_encoding()on the thread, plus anatexitjoin for an in-flight load.Why
tiktoken._tiktokenis a Rust extension, so that importdlopens a shared object. CPython kills daemon threads mid-flight during finalization (pthread_exitat the next GIL acquisition), and being killed inside the dynamic linker is not survivable:_dl_load_lock→ later SIGSEGVextern "C"frame →abort()→ SIGABRTBoth were observed in ApodexHarness as a signal death after a fully correct protocol stream (
run_finished status=success+finalboth on stdout, stderr empty):tests/sdk_cli/test_protocol_compliance.py::test_stateless_across_invocations-11tests/sdk_cli/test_serve_command.py::test_serve_subprocess_e2e-6An all-thread
faulthandlerdump registered atatexit(i.e. the last moment Python code runs, before daemon threads are killed) showed the thread parked inside that import on every short run:Not a rare race: the import takes 24-29 ms and the first
get_encoding_nonblocking()call lands late in a run, so a short process reliably exits inside the window. It is usually non-fatal, which is why it read as a flake. The window is wider whereverTIKTOKEN_CACHE_DIRis unset (CI does not set it, the product Dockerfiles do), because the load then takes the no-timeout network path.Why the import can move but
get_encodingcannotThe module's stated defense is against a minutes-long, no-timeout HTTP fetch freezing the event loop (the 2026-06-05 182 s hang, the 2026-06-08 swarm-gv wedge). That fetch is in
get_encoding(), which stays on the daemon thread. The import is local: 24-29 ms, no network. So the intent is preserved.The
atexitjoin follows the existing precedent inproviders/nonblocking_stream.py, which already joins its daemon thread with a 1 s timeout — this module was the one spot that omitted it.Behaviour change for consumers
None to the contract:
get_encoding_nonblocking()keeps its signature, still returnsNonewhile loading, callers still fall back to a heuristic. One timing difference: the first call for an encoding name now spends 24-29 ms importing on the calling thread where before it returned instantly. In exchange the encoder lands sooner, shortening the heuristic-fallback windowtokens.pydocuments as "the opening turns of every process".Tests
tests/test_tokenizer_nonblocking.py(new, 5 cases). The regression guard installs asys.meta_pathfinder that records which thread loadedtiktokenand which calledget_encoding, and asserts import-on-caller / encode-on-daemon. Also covered: the atexit hook actually drains and the thread deregisters itself; a wedgedget_encodingnever blocks the caller; missing tiktoken is terminal and spawns no thread; concurrent callers spawn exactly one thread.Local:
ruffclean,pyright0 errors,check_unconsumed_fieldsclean,1479 passed, 1 skipped, version bumped 0.8.0 → 0.8.1 with a CHANGELOG entry.Honest limitation
The crash itself was not reproduced on the investigation host (420 isolation runs across four harnesses, 0 non-zero exits — Linux 5.10 / glibc / CPython 3.12.3). The mechanism is the only hypothesis that explains both
-11and-6, and the mid-import park is 100% reproducible, but this is a fix for a diagnosed hazard rather than a fix verified by a now-passing reproducer.Full investigation record:
temp/2026-09-05_serve-post-final-signal-death.mdin ApodexHarness.