feat: Workspace filesystem cleanup #391
12 issues
Medium
forceStopDaemon bypasses centralized cleanup with a direct unlinkSync fallback - `src/cli/daemon-control.ts:58-66`
When no registry entry is found for the socket path, forceStopDaemon falls back to unlinkSync(socketPath) directly instead of going through cleanupWorkspaceDaemonFiles. This creates a parallel cleanup path that skips the workspace-keyed mutation lock and ownership/PID checks that the rest of this PR introduces, and silently swallows the error. Per the runtime boundary guardrails ("Avoid silent fallbacks and parallel invocation paths"), this fallback can race with another live process owning the same socket path and remove its socket without an ownership check.
New 'Running tests' progress lines unrelated to workspace cleanup added to fixture - `src/snapshot-tests/__fixtures__/cli/macos/test--success.txt:16-17`
The hunk adds two 'Running tests (N completed, ...)' lines to the fixture in addition to the workspace-scoped Derived Data path change. The PR scope is workspace filesystem cleanup, which justifies the path change, but the added progress output is not explained by the described behavior change. If these lines reflect volatile/runtime progress, they should be normalized in code rather than baked into the fixture; if they reflect an intentional output change, that change is outside the stated scope of this PR. This risks fixture updates being made primarily to make tests pass rather than reflecting an intentional, in-scope behavior change.
Also found at:
src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt:11-18src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt:9-34
Forced-shutdown timeout is never cleared, causing duplicate cleanup and exit-code race - `src/daemon.ts:344-351`
When shutdown() runs, the 5-second setTimeout is scheduled but never cleared once server.close() succeeds. If cleanup or flushAndCloseSentry(2000) together take longer than 5 seconds (the sentry flush alone allows up to 2s, and stopOwnedSimulatorLaunchOsLogSessions defaults to 1s plus daemon-file cleanup), the timeout fires and runs cleanupArtifacts() a second time concurrently with the in-progress cleanup, then races to call process.exit(1) against the success path's process.exit(exitCode). This can corrupt the daemon-file/socket cleanup (two concurrent cleanupWorkspaceDaemonFiles and stopOwnedSimulatorLaunchOsLogSessions calls) and override the intended exit code with 1.
Also found at:
src/daemon.ts:334-351
removeStaleSocket unlinks paths under /tmp without owner/type validation - `src/daemon/socket-path.ts:29-36`
removeStaleSocket(socketPath) now operates on a path inside tmpdir(). Combined with the predictable workspace-derived directory name from daemonDirForWorkspaceKey, a local attacker who controls the parent directory could place a symlink at d.sock pointing at a victim-owned file; unlinkSync on the symlink is harmless, but if the attacker pre-creates the directory and the daemon proceeds to mkdir/bind without ownership checks (see related finding), arbitrary deletion is possible via earlier startup paths. The change broadens the trust boundary because the original ~/.xcodebuildmcp was not multi-user writable.
Cooldown markers updated before sweep runs, suppressing later sweeps if scheduled run fails - `src/utils/workspace-filesystem-lifecycle.ts:411-415`
scheduleWorkspaceFilesystemLifecycleSweep writes lastScheduledAtByScope and lastScheduledAtByPreKey synchronously before the setTimeout fires. If the deferred runWorkspaceFilesystemLifecycleSweep throws synchronously during resolveOptions or is skipped by lock/cooldown, subsequent schedule attempts within WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS are silently dropped even though no sweep ever executed. This can cause log retention to stop working under transient errors until the cooldown elapses.
Low
Build Logs path retains legacy non-workspace location - `src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt:100`
The Derived Data path was updated to the new workspace-scoped layout (workspaces/XcodeBuildMCP-<HASH>/DerivedData/...), but the Build Logs line at the bottom still points at <HOME>/Library/Developer/XcodeBuildMCP/logs/... rather than a workspace-scoped logs path. If the workspace cleanup change moves logs under the workspace directory (as the PR description suggests), the MCP, CLI, and JSON fixtures may be out of alignment, violating the 'MCP, CLI, and JSON fixture updates stay aligned' guardrail. If logs intentionally remain global, this is fine.
compactWorkspaceKey may collide across distinct workspace keys - `src/daemon/socket-path.ts:16-19`
compactWorkspaceKey extracts the trailing -<12-hex> suffix when present, otherwise hashes the entire key to 12 hex chars. If a caller passes a workspace key that does not match the canonical name-<hash> shape (e.g. raw path or legacy key), the function falls back to hashing the whole input. Two different inputs that share the same 12-hex suffix produced by workspaceKeyForRoot will map to the same daemon directory, leading to socket/registry collisions across workspaces. The probability is low (2^-48 birthday) but the function silently merges namespaces without any check.
isPidAlive returns incorrect results for pid <= 0 or non-integer inputs - `src/utils/process-liveness.ts:1-8`
process.kill(0, 0) signals the caller's entire process group and returns true unconditionally; negative pids target a process group instead of a single process. Non-integer/NaN pids cause process.kill to throw a TypeError whose .code is undefined, so the code !== 'ESRCH' check falsely reports the process as alive. Callers like fs-lock.ts and daemon-registry.ts pass staleOwner.pid / entry.pid without first validating pid > 0 within the visible code paths, so a malformed lock file or registry entry could cause the recovery logic to permanently treat a non-existent owner as alive and refuse to reclaim locks or clean up artifacts.
cleanupOwnedWorkspaceFilesystemArtifacts skips daemon cleanup when daemonCleanup is not provided - `src/utils/workspace-filesystem-lifecycle.ts:482-484`
On shutdown/force-stop, cleanupWorkspaceDaemonFiles is only invoked if the caller passes options.daemonCleanup. In the unconfigured-workspace branch (no workspaceKey), the function returns a zero result without attempting any simulator session stop or daemon cleanup, even though stale artifacts may still exist for the runtime's workspace. Callers relying on this for a complete shutdown sweep may leave daemon files behind.
runStartupLifecycleSweep defined but never invoked in hunk - `src/daemon.ts:152-171`
The hunk defines a runStartupLifecycleSweep arrow function but does not show it being called within the visible changes. If it is never invoked, this is dead code; if invoked elsewhere, the indirection adds complexity without clear benefit. Per the simplifier principle of avoiding redundant abstractions, consider inlining the try/catch at the call site rather than wrapping it in a named lambda.
...and 2 more
14 skills analyzed
| Skill | Findings | Duration | Cost |
|---|---|---|---|
| xcodebuildmcp-docs-release-review | 0 | 3m 27s | $0.06 |
| xcodebuildmcp-docs-command-review | 0 | 6.5s | $0.02 |
| xcodebuildmcp-runtime-boundary-review | 1 | 35.8s | $0.98 |
| xcodebuildmcp-snapshot-fixture-review | 2 | 5m 32s | $2.58 |
| xcodebuildmcp-structured-output-review | 0 | 1m 1s | $2.59 |
| xcodebuildmcp-test-boundary-review | 0 | 3m 26s | $11.92 |
| xcodebuildmcp-tool-contract-review | 0 | 3m 38s | $0.09 |
| wrdn-pii | 0 | 9m 30s | $16.84 |
| wrdn-authz | 0 | 11m 3s | $1.46 |
| wrdn-code-execution | 0 | 6m 18s | $3.73 |
| wrdn-data-exfil | 0 | 10m 18s | $1.35 |
| find-bugs | 6 | 13m 10s | $7.82 |
| code-review | 0 | 14m 48s | $2.59 |
| code-simplifier | 3 | 13m 40s | $3.51 |
Duration: 96m 34s · Tokens: 13.1M in / 42.1k out · Cost: $55.60 (+dedup: $0.05, +merge: $0.01, +extraction: $0.00, +fix_gate: $0.00)