{
    "type": "article",
    "title": "Why a forked worker can wait for ever",
    "canonical": "https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever",
    "path": "/writing/why-a-forked-worker-can-wait-for-ever",
    "url": "https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever",
    "markdown": "https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever.md",
    "json": "https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever.json",
    "summary": "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.",
    "published": "2026-11-03T22:30:00+00:00",
    "modified": "2026-11-03T22:36:22+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/why-a-forked-worker-can-wait-for-ever/hero.png",
        "alt": "28 minutes in, and not a second of CPU. The code pass of a Trawl reindex started through Mullet’s MCP server, 3 November 2026. top shows the MCP server, 1080119, at 13:38 of CPU time, and its three workers, 1523106, 1523107 and 1523108, in state S at 0:00.00. ps shows each worker waiting in futex_wait_queue, 28:46 after it started. Below: the job still reported “running”."
    },
    "fields": {
        "image_dark": null
    },
    "tags": [
        {
            "name": "Python",
            "url": "https://www.dealmobile.co.uk/writing/tag/python",
            "json": "https://www.dealmobile.co.uk/writing/tag/python.json"
        },
        {
            "name": "Linux",
            "url": "https://www.dealmobile.co.uk/writing/tag/linux",
            "json": "https://www.dealmobile.co.uk/writing/tag/linux.json"
        },
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/writing/tag/mullet",
            "json": "https://www.dealmobile.co.uk/writing/tag/mullet.json"
        }
    ],
    "projects": [
        {
            "name": "Mullet",
            "url": "https://www.dealmobile.co.uk/projects/mullet",
            "json": "https://www.dealmobile.co.uk/projects/mullet.json"
        }
    ],
    "body": {
        "html": "<p>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 <code>futex_wait</code>, is the signature.</p>\n<h2 id=\"what-it-looked-like\">What it looked like</h2>\n<p>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 <code>futex_wait_queue</code>, and the job still said “running”.</p>\n<p>The same pool reindexed from the command line, with <code>mullet reindex --pool &lt;pool&gt; --rebuild</code>, gave 39,666 chunks in 329 seconds with no errors. The pool was fine. What differed was where the workers were started from.</p>\n<h2 id=\"the-cause\">The cause</h2>\n<p>The MCP server talks to its client over standard input. Its main thread waits in <code>sys.stdin.readline()</code> for the next request, and while it waits it holds stdin’s lock (<code>mullet/server/mcp.py</code>, line 351).</p>\n<p>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.</p>\n<p>Two pools did this: the code chunker’s (<code>mullet/knowledge/code/chunker.py</code>, line 249) and the one that builds the BM25 keyword index (<code>mullet/knowledge/engine/search/searcher.py</code>, line 875). Fix only the first and the reindex hangs in the second instead.</p>\n<p>The command line never hit it, because <code>mullet reindex</code> forks from its main thread and no thread is holding stdin’s lock.</p>\n<h2 id=\"the-fix\">The fix</h2>\n<p>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, <code>mullet/knowledge/process_pools.py</code>, which both pools pass to <code>ProcessPoolExecutor</code> as <code>mp_context</code>:</p>\n<pre><code class=\"language-python\">def pool_context() -&gt; Optional[BaseContext]:\n    if threading.current_thread() is threading.main_thread():\n        return None\n    return multiprocessing.get_context(\"spawn\")\n</code></pre>\n<p>On the main thread it returns <code>None</code>, 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.</p>\n<p>A job thread that dies now ends its job as “failed”, instead of leaving it “running” for ever.</p>\n<h2 id=\"a-test-that-fails-instead-of-hanging\">A test that fails instead of hanging</h2>\n<p>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.</p>\n<p>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.</p>\n<h2 id=\"the-signature\">The signature</h2>\n<p>A worker at 0% CPU, in state S and waiting in <code>futex_wait</code>, 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.</p>"
    },
    "markdown_text": "---\ntitle: 'Why a forked worker can wait for ever'\ncanonical: 'https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever'\nurl: 'https://www.dealmobile.co.uk/writing/why-a-forked-worker-can-wait-for-ever'\ntype: Post\npublished: '2026-11-03'\nsummary: '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.'\n---\n\n# Why a forked worker can wait for ever\n\nPublished 2026-11-03\n\n![28 minutes in, and not a second of CPU. The code pass of a Trawl reindex started through Mullet’s MCP server, 3 November 2026. top shows the MCP server, 1080119, at 13:38 of CPU time, and its three workers, 1523106, 1523107 and 1523108, in state S at 0:00.00. ps shows each worker waiting in futex_wait_queue, 28:46 after it started. Below: the job still reported “running”.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/why-a-forked-worker-can-wait-for-ever/hero.png.webp?itok=MTU8ipKj)\n\nReindexing 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.\n\n## What it looked like\n\nOn 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”.\n\nThe 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.\n\n## The cause\n\nThe 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).\n\nA 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.\n\nTwo 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.\n\nThe command line never hit it, because `mullet reindex` forks from its main thread and no thread is holding stdin’s lock.\n\n## The fix\n\nPools 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`:\n\n```python\ndef pool_context() -> Optional[BaseContext]:\n    if threading.current_thread() is threading.main_thread():\n        return None\n    return multiprocessing.get_context(\"spawn\")\n\n```\n\nOn 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.\n\nA job thread that dies now ends its job as “failed”, instead of leaving it “running” for ever.\n\n## A test that fails instead of hanging\n\nA 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.\n\nThe 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.\n\n## The signature\n\nA 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.\n\n- **Projects:** [Mullet](https://www.dealmobile.co.uk/projects/mullet.md)\n- **Tags:** [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [Linux](https://www.dealmobile.co.uk/writing/tag/linux.md), [Mullet](https://www.dealmobile.co.uk/writing/tag/mullet.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}