Micah Stubbs' Weblog

Thread locks don’t cross process boundaries

25th January 2026

Thread locks don’t cross process boundaries

I’ve been building a voice-to-text daemon that transcribes speech and injects it into my terminal using xdotool. Today I hit a bug that took me way too long to diagnose. The symptoms were genuinely weird, which is why I’m writing this up.

The transcription was working perfectly. I could see the correct text in my logs. But what appeared on screen looked like this:

PAl efaosoet eard dt haa tf osohtoewrs  tthhaet csuhrorwesn tlhye

That was supposed to be “Please add a footer that shows the currently deployed…”

Finding the culprit

I used Jesse Vincent’s systematic-debugging skill, which forced me to gather evidence before jumping to conclusions. The logs showed correct transcription. The code looked fine. My threading lock was in place.

Then I ran ps aux | grep voice_input and immediately saw the problem:

m  899547  python3 -m voice_input.daemon --claude
m  958207  python3 -m voice_input.daemon

Two daemon instances. I’d started one earlier and forgotten about it. Both were listening to the same microphone, both transcribing, both calling xdotool type at the same time.

Why the output was garbled

When two processes call xdotool type simultaneously, their keystrokes interleave character-by-character:

  1. Daemon A sends P
  2. Daemon B sends l
  3. Daemon A sends e
  4. Daemon B sends e
  5. …and so on

The result is alphabet soup. Both daemons were doing everything correctly in isolation. The bug only showed up because they were racing each other.

Why my threading lock didn’t help

I had a lock in my injector class:

class TextInjector:
    def __init__(self):
        self._injection_lock = threading.Lock()

    def inject(self, text):
        with self._injection_lock:
            subprocess.run(["xdotool", "type", text])

The problem: threading.Lock() only coordinates threads within a single Python process. It does nothing to prevent two separate processes from colliding.

This seems obvious once you think about it. But when you’re staring at code that “has a lock” and wondering why there’s still a race condition, it’s easy to forget that locks don’t cross process boundaries.

The fix: PID file locking

The standard Unix solution for daemon singletons is fcntl.flock():

import fcntl
import os
from pathlib import Path

PID_FILE = Path("/tmp/voice-input-daemon.pid")

def _acquire_singleton_lock() -> int:
    fd = os.open(str(PID_FILE), os.O_RDWR | os.O_CREAT, 0o644)

    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        existing_pid = PID_FILE.read_text().strip()
        raise SingletonDaemonError(
            f"Another daemon is already running (PID {existing_pid}).\n"
            f"Stop it with: kill {existing_pid}"
        )

    os.write(fd, f"{os.getpid()}\n".encode())
    return fd  # Must keep fd open to maintain lock

A few things worth noting:

  • LOCK_EX requests an exclusive lock. Only one process can hold it.
  • LOCK_NB makes it non-blocking, so we fail immediately if someone else has the lock.
  • You have to keep the file descriptor open. Close it and the lock releases.
  • The kernel automatically releases the lock when your process exits, even if it crashes.

Now if I accidentally start a second daemon:

$ python -m voice_input.daemon
Error: Another daemon is already running (PID 899547).
Stop it with: kill 899547

What I learned

I keep coming back to the distinction between thread-level and process-level coordination. Any time you’re building something that controls a system-wide resource (audio hardware, keyboard injection, a GPU) you need to think about what happens when multiple instances run simultaneously.

ps aux | grep <program> should probably be higher in my debugging checklist for daemons. It would have saved me an hour today.

I’m also a believer in helpful error messages. Including the existing PID means you can immediately run kill 899547 instead of hunting around to figure out which process to stop.

The fix took five minutes once I understood the problem. Finding the problem took considerably longer.

This is Thread locks don’t cross process boundaries by Micah Stubbs, posted on 25th January 2026.

Next: Claude Code starts faster on Ubuntu when installed via Homebrew

Previous: Viewport Size: a tiny Chrome extension for seeing your viewport dimensions

Monthly briefing

Sponsor me for $10/month and get a curated email digest of the month's most important developments.

Sponsor & subscribe