Skip to content

serve.log grows unboundedly: no rotation across long-lived daemon use #148

@obj-p

Description

@obj-p

Problem

~/.previewsmcp/serve.log is append-only across the daemon's entire lifetime, with no rotation, no size cap, and no cleanup mechanism. The daemon process redirects its stderr to this file at spawn (Sources/PreviewsCLI/DaemonClient.swift:187-194) using seekToEndOfFile() — every restart appends, every restart accumulates.

The only truncation in the codebase is test-only: Tests/CLIIntegrationTests/DaemonTestLock.swift truncates per-test-process to scope CI failure dumps. Production users get no equivalent.

Why "rotate-on-spawn" is insufficient

The daemon spawns only when DaemonProbe.canConnect() returns false (no existing daemon listening). A single daemon survives across all subsequent CLI invocations until killed, system shutdown, or crash. There's no idle-shutdown.

Realistic lifetimes:

  • CI: minutes (one daemon per test run)
  • Local dev: hours to weeks of intensive use against the same daemon

Rotate-on-spawn would only help the across-restart case. A developer with a 2-week-old daemon and 1000s of accumulated invocations would see no benefit.

Size growth ballpark

After PR #141's diagnostic stage markers, each previewsmcp run writes ~30 lines (~80 chars each ≈ 2.5KB). Hot-reload sessions amplify: every file change cascades. Round numbers:

  • 100 invocations/day → 250 KB/day → 1.7 MB/week → 7 MB/month
  • Heavy hot-reload day: 10× that
  • A long-lived dev daemon over months: tens to low-hundreds of MB unbounded

Not catastrophic for a CLI tool, but unprincipled — and the file silently grows behind previewsmcp logs, where users don't see it until they go looking.

Options, ranked

1. Runtime size-based rotation (recommended)

Daemon checks serve.log size periodically. The 2s heartbeat loop in runMCPServer (Sources/PreviewsCLI/MCPServer.swift) is a natural piggyback. When size exceeds threshold (e.g., 5 MB):

  • Rename serve.logserve.log.1 (overwriting any prior .1)
  • ftruncate(stderr_fd, 0) so the daemon's still-open fd resets to offset 0

Implementation gotcha: the daemon's stderr fd is bound to the inode, not the path. Plain FileHandle(forWritingTo:) + manual seekToEndOfFile (current behavior) means writes after a rename go to the renamed inode. Solutions:

  • (a) Open with O_APPEND in spawnDaemon() so writes always go to the current end-of-file regardless of seek state. After truncate, that's offset 0 of the surviving inode.
  • (b) Skip rename; just ftruncate the live fd. Drops the .1 history entirely. Simpler, less recovery info on failure.

Recommended: (a). One-deep history is cheap and useful for "what happened just before this rotation."

~30 lines, lives in a new LogRotation helper in PreviewsCore. Subsumes "rotate-on-spawn" since the first heartbeat after a spawn that finds an oversized file rotates it.

2. Rotate-on-spawn-if-large

Cheap; ~5 lines in spawnDaemon(). Bounds growth across daemon restarts but doesn't help long-lived daemons. Insufficient on its own per the lifetime analysis above.

3. Idle-shutdown

Daemon exits after N minutes of inactivity. Implicit rotation: each new daemon = fresh log. Behavioral change for users (cold-start latency on next invocation), separate desirability call. ~20 lines in DaemonLifecycle.

4. Configurable retention

PREVIEWSMCP_LOG_MAX_SIZE env var, multi-deep .1/.2/.3 rotation. Overkill given current usage.

Suggested scope

Single PR for option (1) alone. Follow-up only if real users report disk-pressure issues that (1) doesn't address.

Why this is filed separately from PR #141

PR #141 substantially expanded the volume of daemon-side logging (~25 stage-marker call sites added) and added timestamps to every line — both meaningfully accelerate this growth. But the underlying unboundedness has existed since the daemon's stderr-redirect was introduced (PR #133 era). Bundling rotation into PR #141 would overscope what's already a 30+ commit branch.

🤖 Filed from a Claude Code session reviewing PR #141.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions