Skip to content

fix: create the state directory before the auto-spawn path writes into it - #90

Merged
V3RON merged 1 commit into
mainfrom
fix/state-dir-autospawn
Sep 22, 2026
Merged

V3RON merged 1 commit into
mainfrom
fix/state-dir-autospawn

Conversation

@V3RON

@V3RON V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

The bug

On a machine that has never run Appduct, the first command fails:

$ appduct daemon start
Error
  Type     connection_error
  Message  ENOENT: no such file or directory, open '/Users/me/.appduct/daemon.spawn.lock'

~/.appduct is created by startDaemon — but that runs inside the daemon child, and the two things the auto-spawn path writes are opened by the parent, before that child exists:

  1. acquireSpawnLockopen(paths.spawnLockPath, "wx"), which only swallows EEXIST;
  2. defaultSpawnopen(context.logFilePath, "a") for the detached child's stdio, one line behind it.

So every command that auto-spawns hits it: appduct ls, appduct daemon start|status, and — worst — appduct mcp, which dies before an MCP client can finish initialize. An agent that adds the MCP server on a clean install gets a hard failure until a human runs appduct daemon run in the foreground once. appduct init doesn't help: it writes the project .appduct/config.json, never the state dir.

Foreground appduct daemon run was always fine — it calls ensureStateDir first.

The fix

spawnDaemonAndWait calls ensureStateDir(paths.root) before taking the lock, which covers the log fd behind it too. The directory gets the same 0700 the daemon would have applied, and ensureStateDir is idempotent, so the existing-dir path is unchanged apart from a mode re-tighten it already does on every daemon start.

Why it was never caught

All 64 test files mkdtemp their state dir before invoking anything, so the directory always exists. The regression test deliberately points at a path that does not exist and asserts the directory is there by the time the spawn runs (checked inside the injected spawn fn, since that is what defaultSpawn's log fd depends on). Without the fix it fails with exactly the reported ENOENT on the lock path.

Verification

  • Full suite green: 741 passed, 1 skipped, 64 files. typecheck, lint and check:links clean.
  • New test confirmed red before the fix, green after.
  • Built CLI against a nonexistent APPDUCT_STATE_DIR: the directory is now created, the daemon is spawned, and it writes key.pem, audit/ and daemon.log. On this machine the spawned daemon then hits EADDRINUSE on 8443 because an unrelated daemon already holds the port — with the port free (dir pre-created, wssPort: 0), appduct ls auto-spawns and answers normally.

…o it

`~/.appduct` was only ever created by `startDaemon`, i.e. inside the daemon
child — but the spawn-lock and `daemon.log`'s fd are opened by the *parent*
process, before that child exists. On a machine that has never run Appduct,
every command that auto-spawns a daemon (`appduct ls`, `appduct daemon
start|status`, and `appduct mcp`, which died before an MCP client could finish
`initialize`) failed with a bare `ENOENT: ... open '~/.appduct/daemon.spawn.lock'`
until someone ran `appduct daemon run` in the foreground once.

`spawnDaemonAndWait` now calls `ensureStateDir` before taking the lock, which
covers the log fd behind it too; the directory gets the same `0700` the daemon
would have applied.

Every existing test starts from a `mkdtemp`'d state dir, which is why this went
unnoticed — the regression test deliberately points at a path that does not
exist, and asserts the directory is there by the time the spawn runs.
@V3RON
V3RON merged commit 97be91a into main Sep 22, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant