Skip to content

Fix fallback workspace key derivation in forceStopDaemon

699fe46
Select commit
Loading
Failed to load commit list.
Sign in for the full log view
Merged

feat: Workspace filesystem cleanup #391

Fix fallback workspace key derivation in forceStopDaemon
699fe46
Select commit
Loading
Failed to load commit list.
GitHub Actions / warden completed May 4, 2026 in 17m 48s

12 issues

Medium

Volatile test progress lines added to fixture without normalization - `src/snapshot-tests/__fixtures__/cli/swift-package/test--failure.txt:8-14`

The fixture now includes seven 'Running tests (N completed, M failure, ...)' progress lines. These lines depend on test execution timing/ordering and are not normalized like other volatile values (timestamps, PIDs, hashes). Per the skill guardrails, volatile values should be normalized in code rather than baked into fixtures, otherwise the snapshot will be flaky across runs. The Derived Data path change is expected (workspace-scoped path), but these progress lines are a separate concern.

Test failure count jumps from 2 to 4 alongside path-only refactor - `src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt:9-33`

This fixture update mixes two unrelated changes: the workspace-scoped Derived Data path rename, and a substantive change in test discovery/failure counts (52→57 discovered, 2→4 failures, with two new failing test entries). The PR description scopes fixture churn to 'new workspace-scoped paths', but new failing tests and discovered counts indicate a behavior change in the underlying test suite or runner output that is not explained. Per the skill guardrail 'Fixture updates map to intentional behavior changes' and 'Do not update fixtures only to make tests pass', the reviewer cannot verify whether these added failures are intentional or accidental snapshot drift.

forceStopDaemon silently leaves stale socket and registry when SIGTERM target hasn't exited within 500ms - `src/cli/daemon-control.ts:53-57`

After sending SIGTERM and waiting 500ms, forceStopDaemon calls cleanupWorkspaceDaemonFiles with pid set but without allowLiveOwner=true. In canRemoveRegistryEntry, when allowLiveOwner is not true the function returns !isPidAlive(entry.pid); if the daemon hasn't yet exited (500ms is short for a graceful SIGTERM shutdown), removeRegistryAtPathIfOwned returns null and neither the registry entry nor the socket is removed. The previous implementation unconditionally called removeStaleSocket(socketPath), so this is a behavioral regression: ensureDaemonRunning will then proceed to startDaemonBackground while the old socket file still exists, which can cause the new daemon's bind() to fail with EADDRINUSE and leave the workspace unable to start a daemon.

Also found at:

  • src/daemon/daemon-registry.ts:332-344
Forced-shutdown timer can hang indefinitely waiting on async cleanup - `src/daemon.ts:344-351`

The 5s forced-shutdown timer previously called the synchronous cleanupWorkspaceDaemonFiles and then exited. It now awaits the async cleanupOwnedWorkspaceFilesystemArtifacts via .finally() with no timeout. If that promise hangs (e.g., a stuck filesystem lock acquired in fs-lock-shared / workspace-filesystem-lifecycle), the daemon will never exit, defeating the purpose of the forced-shutdown path and causing process leaks across daemon restarts.

Also found at:

  • src/utils/workspace-filesystem-lifecycle.ts:403-410
Daemon socket directory moved to shared tmpdir without per-user isolation enables symlink/TOCTOU attacks - `src/daemon/socket-path.ts:21-31`

daemonRunDir() now returns os.tmpdir() (typically /tmp on Linux, a world-writable shared directory), and daemonDirForWorkspaceKey builds a predictable path xcodebuildmcp-<12-hex> under it. Because workspaceKeyForRoot is a deterministic SHA-256 of the workspace root path, any local user can predict the directory name and pre-create it (or create it as a symlink) before the daemon starts. The directory creator in ensureSocketDir uses existsSync+mkdirSync (TOCTOU) and will not enforce ownership/mode if the directory already exists, which can lead to the daemon binding its UNIX socket inside an attacker-controlled directory and removeStaleSocket unlinking attacker-chosen files. Previously the directory lived under ~/.xcodebuildmcp which was not shared between users.

Also found at:

  • src/daemon/socket-path.ts:16-19
PID reuse can prevent recovery of expired locks indefinitely - `src/utils/fs-lock.ts:74-82`

shouldRecoverLockDir refuses to recover an expired lock if isPidAlive(staleOwner.pid) returns true. On Linux/macOS, PIDs are recycled, so an unrelated long-running process inheriting the recorded PID will keep the expired lock un-recoverable forever. Every future acquirer for that workspace will fail to acquire the lock, producing a denial-of-service for cleanup/daemon operations. Consider also comparing process start time or the owner token before honoring isPidAlive.

Also found at:

  • src/utils/fs-lock.ts:195-220

Low

removeDaemonRegistryEntry and cleanupWorkspaceDaemonFiles silently swallow lock acquisition failures - `src/daemon/daemon-registry.ts:286-289`

Both removeDaemonRegistryEntry (line 286) and cleanupWorkspaceDaemonFiles (line 331) call withDaemonRegistryMutationLock and ignore its null return value. If the lock cannot be acquired within DAEMON_REGISTRY_LOCK_WAIT_MS (1s), cleanup silently no-ops with no error or log, contradicting writeDaemonRegistryEntry which throws in the same situation. This can leave stale daemon registry entries and socket files behind under contention, which is the exact failure mode the multi-process cleanup boundaries described in the PR aim to address.

Also found at:

  • src/daemon.ts:186-197
Successful lock recovery on the last retry attempt is discarded - `src/utils/fs-lock.ts:204-225`

In tryAcquireFsLock, the retry loop runs at most 2 attempts. If the second attempt's createLock fails with EEXIST and tryRecoverExpiredLockDir succeeds, the loop terminates without performing another createLock, so the recovery is wasted and the function returns null. Callers that could have acquired the lock instead see a spurious failure, increasing contention churn.

Rename failure leaves untracked OSLog file and possibly-live helper child - `src/utils/simulator-steps.ts:363-369`

When renameHelperLogPathOrThrow fails after the detached child has already been spawned, the function sends SIGTERM (best-effort) and throws. The outer catch closes the parent fd but never unlinks the original ownerpid log path, and registerSimulatorLaunchOsLogSession is never called. If the detached child survives the SIGTERM, it continues writing to an orphaned, untracked log file that the workspace cleanup logic cannot reconcile because no session was registered. This produces a resource/file leak that grows on each failure.

cleanupOwnedWorkspaceFilesystemArtifacts skips daemon cleanup when workspace key is unresolved - `src/utils/workspace-filesystem-lifecycle.ts:480-492`

When neither options.workspaceKey nor getRuntimeInstanceIfConfigured()?.workspaceKey is available, the function returns early with an 'unconfigured' result. However, this path also skips stopOwnedSimulatorLaunchOsLogSessions, which is workspace-agnostic and would otherwise stop all owned sessions. As a result, on shutdown/force-stop without a configured runtime, owned simulator OSLog helpers are left running rather than being terminated, contradicting the stated goal of cleaning up owned artifacts.

...and 2 more

14 skills analyzed
Skill Findings Duration Cost
xcodebuildmcp-docs-release-review 0 5.1s $0.08
xcodebuildmcp-docs-command-review 0 5.4s $0.08
xcodebuildmcp-runtime-boundary-review 0 6m 4s $0.71
xcodebuildmcp-snapshot-fixture-review 2 2m 27s $9.25
xcodebuildmcp-structured-output-review 0 3m 9s $0.75
xcodebuildmcp-test-boundary-review 0 5m 48s $3.07
xcodebuildmcp-tool-contract-review 0 17.8s $0.14
wrdn-pii 0 12m 12s $5.50
wrdn-authz 0 6m 53s $3.85
wrdn-code-execution 0 7m 46s $1.35
wrdn-data-exfil 0 8m 37s $3.73
find-bugs 8 16m 46s $5.34
code-review 1 13m 26s $5.02
code-simplifier 1 14m 12s $1.12

Duration: 97m 48s · Tokens: 13.2M in / 43.9k out · Cost: $40.06 (+merge: $0.01, +dedup: $0.03, +extraction: $0.01)