Skip to content

Design: the watch daemon has no owner, no serialization, and no reaper #155

Description

@JordanCoin

Version: 4.4.2 (Homebrew) · macOS
Related: the "hook session-start silently spawns a daemon" bug report — that one is about it being undocumented. This one is about whether the daemon should work this way at all.

What I found

A single project directory, observed live:

PID    ELAPSED       COMMAND
44058  5d 04:22:41   codemap watch daemon <project>
81231     06:54:57   codemap watch daemon <project>
81271     06:54:56   codemap watch daemon <project>
81298     06:54:55   codemap watch daemon <project>

Four daemons watching the same directory. Three of them started within one second of each other. The fourth had been running for five days, long after whatever session started it had ended.

That's two separate defects wearing one costume.

Defect 1 — the "already running?" check is not a guard

Three spawns at :55, :56, :57 means the check is read-then-act with a window in between. Any two invocations that land inside that window both conclude nothing is running and both spawn. Editors, shells, and agent tooling all tend to fire session hooks at once, which is exactly the access pattern that loses this race.

A liveness check is not mutual exclusion. This needs an atomic claim — O_EXCL lock file holding the pid, or a unix socket bind, or flock — where losing the claim means exiting rather than proceeding.

Defect 2 — nothing owns the daemon, so nothing reaps it

The five-day-old process is the interesting one. It outlived its session by days, still watching, still holding state, invisible to the user who caused it. There is no parent that dies and takes it with it, no TTL, no "is my originating session still alive" heartbeat, and no documented way to stop one — --help lists --watch to start but nothing to stop.

Over weeks this accumulates silently. I only noticed because I went looking for something else.

The design question underneath

The part I'd actually reconsider is hook session-start spawning it at all.

A watcher you opt into with --watch and can see is a normal tool. A watcher that appears because you asked for a printout — under a subcommand whose help text is "Show project context" — and then outlives the thing that asked, is the shape that produced four copies. The user never formed an intent to run a background process, so they never form an intent to stop one.

Is the daemon buying anything?

Worth asking honestly, because the costs above are real and the benefit may not be. Measured cold, no daemon, on a ~140-file Go repo and a ~290-file Swift repo:

command runtime
handoff 0.59s / 1.00s
--importers <file> 0.30s / 0.52s
--deps 0.52s / 0.89s
blast-radius --text 0.65s / 1.46s
bare tree 0.19s / 0.38s

Everything a consumer actually wants is sub-second to ~1.5s from cold. The daemon is optimizing a cost that doesn't obviously exist at these repo sizes. It would start to matter on a very large monorepo — but that's an argument for making it opt-in for those users, not the default side effect of a print command.

Options, roughly in increasing order of work

  1. Remove the spawn from hook session-start. Print context, exit. Keep --watch for people who want it. Smallest change, removes the whole class.
  2. Keep the spawn but make it safe and owned — atomic lock claim, a TTL or heartbeat tied to the originating session, codemap watch stop [--all], and watch status listing every daemon with its age and project.
  3. Keep it, but earn it — measure the cache hit rate a daemon actually provides. If warm runs aren't meaningfully faster than the numbers above, option 1 wins on evidence.

Happy to be wrong about this if the daemon is carrying weight on repos larger than the ones I tested — but at these sizes I couldn't find the win, and I did find four copies of it.

What still holds up

Worth saying explicitly since this reads as a pile-on: the graph work underneath is sound where the language has real file-level imports. --importers was set-identical to grep -rl (31/31) and deterministic across five runs, and handoff had the best signal-per-token of anything I measured. The problems I hit were all in the surrounding layers — process lifecycle, truncation order, config resolution — not the analysis.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions