Dev.to AI 🤖 Ai 👁 0 📖 5 min read

Kill the thread, not the promise: how my desktop AI companion survives a bad regex

Kill the thread, not the promise: how my desktop AI companion survives a bad regex Ankita is my open-source desktop AI companion — a little companion, a lot more possible. It lives on your desktop, reads your files whe

Kill the thread, not the promise: how my desktop AI companion survives a bad regex

Ankita is my open-source desktop AI companion — a little companion, a lot more possible. It lives on your desktop, reads your files when you ask it to, and runs on your models, local-first, under an MIT licence: https://github.com/akyourowngames/A.N.K.I.T.A

I watched it do something embarrassing: I asked it to search a folder with a sloppy regex, the search went pathological, and the whole companion froze. Not just the search — the chat, the Stop button, everything.

That is the failure this post is about, and the decision at its centre is blunt: when a filesystem search goes bad, Ankita does not cancel the promise. It kills the thread.

The problem: a synchronous regex cannot hear "stop"

Searching files sounds harmless until you remember what a regex can do. A pattern like (a+)+$ against a long run of as is a classic catastrophic-backtracking case: the engine tries an exploding number of ways to match, and while it does, it never yields. JavaScript's AbortSignal cannot help, because a synchronous loop never checks the signal. Rejecting the promise would only lie to the caller — the work would carry on, still burning the thread, still blocking every later inspection.

So the usual "cancellation" story fails twice over: the user presses Stop and the agent keeps searching, and the next innocent read_file queues behind a search that will never finish, because both run on the same event loop. If the code cannot be interrupted, the container holding it has to be disposable.

The decision: four tools, one resident worker, termination on timeout

Only four tools go through the worker at all: search_files, glob, list_dir, and read_file. Everything else runs in-process as before. The allowlist is enforced before any worker starts — ask for run_command through this path and you get Unsupported worker tool without a thread ever being spawned. That narrowness is deliberate: the worker exists for one hazard, filesystem inspection, not as a general sandbox.

The worker itself is a single resident node:worker_threads thread, not a pool, despite the runner file's name. Calls are serialised through a queue with incrementing IDs, so concurrent callers each get their own answer — the tests check this explicitly, eight parallel reads returning eight different file contents.

The rules that matter live in src/tooling/tool-worker.mjs (84 lines) and its runner, src/tooling/tool-worker-pool-runner.mjs (19 lines):

  • Timeouts kill the thread. The default timeout is 5 seconds, capped at 30 seconds even if a caller asks for more. When it fires, the call settles with "Filesystem inspection timed out after …ms. Narrow the search or simplify the regex", and retire() terminates the thread. The next queued call then runs on a fresh worker. The queued read after a timed-out regex still returns — that recovery is tested, not hoped for.
  • Stop kills the thread too. If the running call's AbortSignal fires, the same retire() path runs. Cancelling a queued call, by contrast, just removes it from the queue and leaves the worker alone.
  • Ordinary errors do not kill anything. A read_file on a missing file rejects that one call and the worker serves the next one. Killing the thread is reserved for the case where the code itself cannot be trusted to stop.
  • The worker is lazy when idle. It is created on first use, unref()d when the queue empties so it cannot keep the process alive, and ref()d again when work arrives. Shutdown — on app quit and on CLI exit — settles every pending call, and the next call transparently restarts a fresh worker.

Dispatch happens in one line in src/core/tool-host.mjs: file-worker tools go to the worker, everything else runs directly. The workspace boundary travels with the call — ../ok.txt from a nested directory is fine inside the workspace, while ../../outside.txt is rejected.

What this costs, honestly

A serial worker means head-of-line blocking: one 5-second regex still delays every queued inspection behind it, even though the UI thread stays responsive. Killing a thread also throws away its lazily loaded tool modules, so the next call pays a fresh import. And this is a thread, not a process — it protects the event loop and makes Stop real, but it is not a security boundary against hostile code, only against runaway code. The 30-second cap is a wall clock, not a regex-step budget, so a merely slow pattern can still burn a worker thread's CPU for seconds before it dies.

Those are the trade-offs I accepted: a Stop button that means something, in exchange for serial throughput and a cold start after every kill.

The question I keep coming back to

I chose one serial worker because filesystem inspection in a companion is usually one question at a time, and a single thread keeps the queue, the IDs, and the recovery path small enough to reason about. But the head-of-line blocking is real: a queued read_file still waits behind someone else's bad regex until the timeout fires.

So here is the design question, and I genuinely want it argued with: should every search get its own thread, paying a thread-spawn cost per call so a bad regex can never block a queued read — or is one serial worker with a 5-second kill the right shape, because the bottleneck is really the regex, not the queue? If you have shipped something like this, which side did you land on, and what broke first?

Ankita is open source, and the whole mechanism is small enough to read in one sitting: src/tooling/tool-worker.mjs, its runner, and the tests in test/tools/file-worker.test.mjs and test/tools/worker-watchdog.test.mjs (11 tests, all green on my run). If you think the kill-the-thread call is wrong, or the serial queue is the actual bug, I would rather hear that than a like.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.