When pytest Froze My Mac: Following the Launch Services Trail

My Mac kept freezing. Everything visible stopped: text, graphs, menus, switching Spaces. Activity Monitor froze along with the rest of the desktop, so it could not show me what was happening.

When the display started updating again, launchservicesd sometimes showed more than 200% CPU. The efficiency cores looked saturated. I suspected something deep in macOS.

The cause turned out to be a cosmetic feature of my Python test runner: changing a worker's process name to show which test it was running.

If your Mac becomes unresponsive during parallel pytest runs and launchservicesd gets busy, check whether pytest-xdist is repeatedly updating worker titles through setproctitle. Another project, BenchBox, has documented this same combination and a successful workaround. We found their report after following the evidence on my machine. Since the title updates stopped, the freezes have not come back.

Two innocent suspects

The day before, I had good reason to think the machine was broken at a much lower level. Hoping a reboot would put things right, I restarted — and the machine did not come back. After unlocking FileVault it restarted again, and again, in a loop. An fsck_apfs run from the recovery options repaired real filesystem corruption and got me back in. It was genuinely frightening; I was close to restoring from a backup.

That left me with a strong prior: whatever was wrong lived somewhere around the filesystem or the kernel. The prior was reasonable, the corruption was real, and neither had anything to do with these freezes.

iStat Menus was the second innocent suspect. It lagged more than everything else, it held a lot of connections to launchservicesd, and trying to quit it through its own menu could provoke the frozen desktop on the spot. We unloaded its user agents. For a while there were no freezes. Then they came back.

The relationship ran the other way around. iStat asks Launch Services for data, and after a freeze that daemon was still working through a backlog, so iStat was slow and so was anything that went through it. It was displaying the symptom, not producing it. A program that becomes unresponsive when I open its menu can also be waiting on something shared with the rest of the desktop.

I also had to correct an assumption about the CPU graph. A spike visible after a freeze does not tell me when the spike began. During the freeze, the graph was frozen too. Perhaps Launch Services caused the stall; perhaps work accumulated during another stall and the daemon was busy catching up. The screenshot could not distinguish those explanations.

Evidence that survives an unresponsive interface

We needed evidence collected without my pressing a key at the right moment. With a coding assistant helping inspect the captures, we set up a ten-minute system stack recording at half-second intervals and a separate one-second background heartbeat. I continued working and reported freezes around 11:58 and 12:00.

The heartbeat kept running. Its largest recorded gap was about 1.0104 seconds.

From my perspective, the whole machine was still frozen. The heartbeat narrowed what that meant internally: some background execution continued while the desktop was unusable. It argued against a multi-second halt of all execution. It did not rule out a kernel component blocking particular threads, and buffered heartbeat writes did not prove the disk was completing physical writes normally.

The system stacks supplied the more useful connection. At two checked instants in the reported freeze windows, dozens of processes had their main threads waiting on the same service.

  • 62 process main threads waiting on Launch Services near 11:58 — The dependency extended across the desktop
  • 61 near 12:00 — The pattern appeared again
  • Finder and Activity Monitor handling application-name notifications — A specific shared request path to investigate
  • Ghostty and loginwindow also waiting near 12:00 — The problem reached beyond monitoring tools

These were explicit kernel wait-chain annotations pointing to launchservicesd, rather than a guess based on CPU usage. Several inspected UI stacks were handling an AppKit application-name notification and making a synchronous _LSCopyApplicationInformation request.

We had not independently explained every symptom, including why Spaces switching failed. But we now had a concrete question: who was changing application information so often?

macOS includes lsappinfo, which can listen for those notifications. We started a bounded recording:

/usr/bin/lsappinfo listen +appNameChanged +appInfoKeyChanged wait 600 > app-events.log 2>&1

This runs for ten minutes and requires no keyboard action during a freeze. The log can contain application names and private test identifiers; inspect it before sharing it.

I reported another freeze at about 12:24. For that minute, reconstructing the listener's callback timestamps gave 11,773 application-name notifications. That count excluded the paired information-key notifications. Of those name notifications, 11,769 belonged to two pytest workers in my development workspace.

Their names alternated between forms like these:

[pytest-xdist running] tests/test_example.py::test_something
[pytest-xdist idle]

The callback timestamps describe delivery, which can queue. They are not a precise measurement of when every originating change occurred. Even with that limit, the payloads identified the source of the flood.

The fix that actually reaches the worker

The xdist worker implementation uses setproctitle, when available, to update its process title around test execution: once before a test with the node ID, once after with [pytest-xdist idle]. The feature makes busy workers easier to identify in process viewers. Our application already depended on setproctitle, so it was available to the test runner too.

The obvious way to switch that off does not work, and it fails in a way that looks like success. Assigning a no-op to xdist.remote.worker_title rebinds a name in a module no worker calls: execnet does not import xdist.remote in the worker, it executes its source under the name __channelexec__. BenchBox's notes flag this trap: a plausible-looking monkeypatch that changes a copy will pass a casual check and keep the flood running.

What works is to find the interactor this run actually registered and replace the callback its run_one_test resolves:

def suppressed_worker_title(_title: str) -> None:
    """Leave a worker's process title unchanged."""


def suppress_xdist_worker_titles(config: pytest.Config) -> bool:
    """Disable per-test xdist titles in the executing worker."""
    if not hasattr(config, "workerinput"):
        return False

    for plugin in config.pluginmanager.get_plugins():
        plugin_type = type(plugin)
        if (
            plugin_type.__name__ != "WorkerInteractor"
            or plugin_type.__module__ != "__channelexec__"
            or getattr(plugin, "config", None) is not config
        ):
            continue
        run_one_test = getattr(plugin_type, "run_one_test", None)
        globals_ = getattr(run_one_test, "__globals__", None)
        if globals_ is None or "worker_title" not in globals_:
            continue
        globals_["worker_title"] = suppressed_worker_title
        return True
    return False

It returns False when the process is not an xdist worker — a -n 0 run, or the controller of a parallel one, neither of which renames itself. We call it from pytest_configure in conftest.py, with the import inside the hook rather than at module level: a worker imports the conftest long before it registers the interactor, and the hook is the first point at which the interactor exists.

The patch is only useful if it lands in the right namespace, so that is what a test should pin. The test does not need a real worker. Build a stand-in class named WorkerInteractor with __module__ set to __channelexec__, give it a run_one_test whose globals contain a recording worker_title, and assert afterwards that the name resolves to the no-op. Add a second interactor that belongs to a different config, and assert that it was left alone.

We ended up suppressing the titles on every platform, not just macOS. Nothing in the project reads a worker's process title; it only makes ps and htop output nicer. Against that, rewriting the process image twice per test is a liability that has now cost real debugging time on one operating system and might on another. When a cosmetic feature turns out to have a systemic cost, guarding it per-platform keeps the cost around. Parallel execution stayed enabled, and the application can still use setproctitle for its own purposes.

What held

The independent report made the picture much stronger. BenchBox's investigation describes beachballing even with two workers, Launch Services CPU reaching 228% during parallel execution, and the load disappearing when worker-title updates were disabled. Discarding terminal output did not solve it. Their mitigation also suppresses worker titles on macOS while retaining parallel tests.

The freezes have not come back. Ordinary parallel test runs have not produced one since the fix went in.

The evidence implicates repeated worker-name updates as the trigger on my machine. It does not establish the precise defect inside macOS that lets those updates stall so much of the desktop, and it does not explain every symptom I saw — Spaces switching among them.

What helped most was collecting evidence that could survive an unresponsive interface. The heartbeat distinguished a frozen desktop from a halt of all execution. The system stacks exposed a shared dependency. The notification log named the behavior feeding it. Once we had that sequence, we could change one small thing and keep the actual workload running.