Why a forked worker can wait for ever
A Trawl reindex started through Mullet’s MCP server hung at 0% CPU, because its forked workers inherited a lock the main thread was holding. Pools started off the main thread now use spawn, and a test fails instead of hanging.
Reindexing a Trawl pool through Mullet’s MCP server no longer hangs in its code pass. The cause is a trap worth knowing if you write Python: forking a process that has more than one thread copies every lock in whatever state it’s in. If another thread held one at that moment, the child can wait for it for ever. A worker at 0% CPU, asleep in futex_wait, is the signature.
What it looked like
On 3 November a reindex started through Mullet’s MCP tool finished its document pass normally: 5,515 files and 19,155 chunks in about 14 minutes. Then the code pass started three worker processes. After 28 minutes they still showed 0:00 of CPU time. They were in state S, waiting in futex_wait_queue, and the job still said “running”.
The same pool reindexed from the command line, with mullet reindex --pool <pool> --rebuild, gave 39,666 chunks in 329 seconds with no errors. The pool was fine. What differed was where the workers were started from.
The cause
The MCP server talks to its client over standard input. Its main thread waits in sys.stdin.readline() for the next request, and while it waits it holds stdin’s lock (mullet/server/mcp.py, line 351).
A reindex runs on a job thread, and the worker pools were forked from there. Fork copies the process with that lock still taken, but only the thread that called fork comes along, so in the child nothing will ever release it. Multiprocessing closes stdin in every new worker as it starts, closing waits for the lock, and the worker waits for ever.
Two pools did this: the code chunker’s (mullet/knowledge/code/chunker.py, line 249) and the one that builds the BM25 keyword index (mullet/knowledge/engine/search/searcher.py, line 875). Fix only the first and the reindex hangs in the second instead.
The command line never hit it, because mullet reindex forks from its main thread and no thread is holding stdin’s lock.
The fix
Pools started off the main thread now use the spawn start method. A spawned worker is a fresh interpreter rather than a copy of its parent, so it inherits no locks at all. The choice is one function in a new module, mullet/knowledge/process_pools.py, which both pools pass to ProcessPoolExecutor as mp_context:
def pool_context() -> Optional[BaseContext]:
if threading.current_thread() is threading.main_thread():
return None
return multiprocessing.get_context("spawn")
On the main thread it returns None, the platform default, so the command line still forks and keeps its quicker start. Spawn has a cost on the server path: each spawned BM25 worker imports torch from scratch.
A job thread that dies now ends its job as “failed”, instead of leaving it “running” for ever.
A test that fails instead of hanging
A hang is awkward to test, because a test that hangs never reports. The new test starts a child process laid out like the server: its main thread blocks on standard input, and a reindex of a small temporary pool runs on another thread. The test waits with a timeout, so if the bug comes back the test fails rather than sitting there.
The fix was merged on 3 November 2026, in pull request #2 of Mullet’s development repository (merge commit e6322ee), with the full suite passing locally: 18,667 passed, 42 skipped.
The signature
A worker at 0% CPU, in state S and waiting in futex_wait, forked from a process with more than one thread: look for a lock another thread held at the moment of the fork. The remedy is to start that pool with spawn, at least whenever it’s started from anything but the main thread.
More on Mullet
-
Mullet’s code is private now
I’ve made Mullet’s code private while I work on a new idea. Everything I’ve written about it stays here, and if you’d like to know more or ask about…
-
Checking third-party repos for prompt injection before an AI reads them
Before 18 public repos went into a search pool that Claude sessions read, I scanned every text file for hidden text and instruction-shaped strings…
-
Tuning a search pool: content beat weights
On a Trawl pool of 18 public repos, every search-time setting I tried scored exactly the same, because the reranker sets the final order. What moved…
-
Where Mullet goes next
Mullet 2.5.0 is out. What I plan next: a guard that checks a step fits a model’s window, a price ceiling instead of a free switch, more settings and…
-
Eight rules from a game framework’s agent prompts, for web and print design
On 1 November I read the agent prompts of VibeGame, a framework that builds 2D web games, and kept the eight techniques that carry over to web and…
-
What I tried and binned, and what each one cost
Three local-model options I stopped using, with what each cost and the verdict: a 25 GB model that timed out in a job, a Blackwell-only fork ruled…
-
When your plan has no five-hour limit
Mullet 2.5.0 copes with a Claude plan whose usage data has no five-hour window: the HUD drops the five-hour figures and the budget guard paces on…
-
Very cheap is basically free: pricing a coding model per step
A free model id in my config silently had no hosts, and the real price of a cheap coding model depends on which hosts you will accept. The per-step…
Featured projects
-
PrintMD
A Markdown editor that exports print-quality PDF, Word and PowerPoint files, with a small PDF service that runs on your own computer.
-
Solar Calendar
A calendar of 13 identical 28-day months, with each year starting on the first Monday on or after the spring equinox.
-
Where Should I Live
Rank cities around the world by what you’d keep after tax, what a month costs, which visas you can get and how each place fits the way you live.
-
Apple News API
A PHP client for publishing articles to Apple News, with a document builder for the whole Apple News Format.
More writing
-
A PDF service that only runs on your machine
PrintMD’s PDF service turns Markdown into a PDF with one request, and it refuses to be hosted. Why that’s on purpose, and how it stays locked down…
-
Custom sizes, one long page and fit to width
Not every PDF is an A4 page. PrintMD’s page options cover a custom size, one continuous page, scaling wide tables to fit and your own margin, and…
-
Print-quality PDFs from a browser
A browser can print any page to PDF, but a document you send someone needs more. How PrintMD gets fonts, page breaks and images right, and what its…
-
Why I built PrintMD
I write in Markdown, and getting from there to a PDF I’d happily send someone was more work than it should be. So I built an editor that shows the…