fix(daemon): graceful watchdog exit (no orphaned apps) + built-in log size cap - #464
Merged
Merged
Conversation
The rx watchdog's hard escalation called os.Exit(86) directly, skipping cmd/daemon's Daemon.Stop + runtime.StopPlugins. StopPlugins is what stops the app-store supervisor and terminates its child apps; they run in their own process group, so every watchdog respawn orphaned all of them (94 copies of each of 12 apps, ~2.8 GB RSS, on one laptop in a week). - pkg/daemon/exit.go: requestSupervisorExit hands the exit to a host handler (SetExitHandler) and arms a 15s hard deadline that os.Exits with the same code if the teardown hangs. No handler installed -> immediate exit, the previous behavior for embedders. - rxWatchdogExit (the test seam) now requests the graceful exit; the watchdog loop stops ticking once it has, so a slow teardown can't record a second exit against the restart-loop breaker. - cmd/daemon: the shutdown loop receives exit requests alongside signals and fleet lifecycle requests, runs the normal teardown, then exits with the requested code so launchd/systemd still respawn. Daemon.Stop gets an 8s budget after which plugins are stopped anyway (a wedged transport can hang it). Startup failures after StartPlugins now stop plugins before exiting instead of log.Fatalf. pathwatch.go only borrows the seam pattern; it never exits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
launchd never rotates StandardOutPath/StandardErrorPath, so ~/.pilot/daemon.log grew without bound (22 MB on one laptop; a June log was 13 MB gzipped). internal/logcap watches stderr when it is a regular file: every minute it fstats the descriptor and, past the limit, copies the file to <path>.1, truncates the original through the daemon's own descriptor (rewinding first, for non-O_APPEND writers), then gzips the copy into <path>.1.gz, shifting older generations up to <path>.N.gz. launchd and pilotctl open the log O_APPEND and children share the file description, so truncation is safe for every writer. The path is resolved from the fd (darwin F_GETPATH, linux /proc/self/fd) and checked against the fd's inode; an unlinked log is still truncated, without a backup. No-op for journald, terminals, pipes, and platforms without fd->path mapping. Flags: -log-max-size (MB, default 50, 0 disables) and -log-max-backups (default 3); both also settable from config.json via ApplyToFlags. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| // Daemon.Stop then StopPlugins (see teardown for why the order | ||
| // matters). A daemon-requested exit leaves here via os.Exit with its | ||
| // code once the teardown finishes. | ||
| shutdown(cause, func() { d.Stop() }, rt.StopPlugins, os.Exit) |
Review findings on the log cap (#464): 1. It deleted other rotators' history. shiftBackups pruned every <log>.N.gz up to the first gap, and finishInterrupted consumed any <log>.1 as its own staging copy. Those are the names logrotate gives the same file (compress / delaycompress), so an operator with `rotate 7` lost generations 4-7 when the log first passed 50 MB. - Generations are now <log>.pilot.N.gz, staged via <log>.pilot.1, apart from logrotate's and newsyslog's names. - Within those names it reads, renames or removes only a regular file owned by the current user. Every kept slot is checked before the shift moves anything, and pruning stops at the first gap or foreign file. - Default scope: with the default -log-max-size, only a log inside ~/.pilot (or $PILOT_HOME/.pilot) is rotated, which covers launchd's daemon.log and pilotctl's pilot-<pid>.log. A log elsewhere is rotated only when -log-max-size is set explicitly, on the command line or in config.json. ApplyToFlags does not mark flags it sets, so the config map is checked directly. A watched log that moves out of scope stops being rotated. 2. It followed planted symlinks. <log>.1 and <log>.1.gz.tmp were opened O_CREATE|O_TRUNC and the staged copy was read through os.Open. In a directory another user can write to, that allowed arbitrary overwrite as the daemon user, or gzipping identity.json into the backup chain. - Staging and temp files are created O_CREATE|O_EXCL|O_NOFOLLOW. A stale temp is removed first only if it is ours. - Everything read back is opened O_NOFOLLOW and must be a regular file owned by the current uid. The live log itself is opened O_NOFOLLOW and must be the same file as our descriptor. - If the log's directory is writable by group or others (sticky /tmp included), or owned by another non-root user, rotation only truncates and creates no files. - A rename never replaces a file that is not ours. Tests: - logrotate/newsyslog/dateext files are preserved. - Symlinks at the legacy names (.1, .1.gz.tmp) and at every logcap name are not followed, and are left in place. - A .pilot.1 link to a secret is not consumed. - Other users' files at each name are left untouched: simulated through an ownership hook, and with real chown when the tests run as root (linux container). - Shared and foreign-owned directories only truncate. - Scope checks: Watch within, nested, via a symlinked dir; out of scope; a log that leaves scope. - flagExplicit and pilotDirs. Mutation-checked: disabling each guard fails at least one test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d rotations Re-review findings on the log cap (#464): 1. `brew services` logs were still unbounded (464-R1). Pilot's Homebrew formula (TeoSlayer/homebrew-pilot) runs the daemon with log_path and error_log_path var/"log/pilot-daemon.log". launchd, or systemd with StandardOutput=append:, opens that file and nothing rotates it. It was outside the default scope, which was ~/.pilot only. - New logcap.Options.Files puts single log files in scope. A file matches by name in the same directory, with the directory compared by identity, so the rest of <prefix>/var/log, which belongs to other formulae, stays out of scope. - The daemon adds <prefix>/var/log/pilot-daemon.log when its executable resolves to <prefix>/Cellar/pilotprotocol/<ver>/bin/ pilot-daemon, the formula's binary, which the service runs through opt/pilotprotocol. Any other install is unchanged. - Flag help and CHANGELOG now name the Homebrew log. They no longer say "under systemd" for a no-op, since brew's systemd unit appends to a file. 2. Interrupted rotations of other logs were orphaned (464-R2). `pilotctl daemon start` names each daemon's log pilot-<pid>.log, so a <log>.pilot.1 left by a daemon killed mid-rotation (up to the full log size, uncompressed) was under a name no later daemon looked at. - After its own rotation, a rotation now finishes the <other>.pilot.1 copies in its directory into <other>.pilot.1.gz. It acts only on regular files of ours; anything else is left alone. It does not shift or prune the other log's generations. - Liveness: a rotation holds an exclusive flock on its staged copy from creation until it is compressed. The lock is held through a second descriptor, so closing the writer still reports a delayed write error before the truncate. A copy that is locked belongs to a rotation in progress and is skipped. Death (SIGKILL, OOM) and exec release the lock. An empty copy of another log is skipped, because it may be in the instant between creation and lock. On a filesystem without flock, other logs' copies are skipped and the log's own rotation works as before. - A log's own leftover copy goes through the same lock. If a rotation in progress holds it, or it cannot be finished, this round keeps no backup and moves nothing. Before, the shift ran first and then failed on the occupied name, dropping the oldest generation for nothing. Not changed here: the formula's `keep_alive crashed: true` does not respawn the daemon after the watchdog's exit 86. That is pre-existing, and the fix belongs in the formula. Tests: - Files scope: the file itself, through a symlinked dir, and next to Within match; a neighbour, the same name in another dir or a subdir, and a missing dir do not. A renamed Files log stops being rotated. - homebrewServiceLog: the Cellar, opt and linked-bin paths map to var/log/pilot-daemon.log. ~/.pilot/bin, a manual install, another formula, another binary and a missing path do not. - Recovery: another log's copy is finished without shifting its generations. A locked copy is left until the lock goes away. Empty copies, links, directories, other users' files, a bare ".pilot.1", similar names and the parent directory are untouched. A locked own copy stops the shift. The lock is released every round. createStaged holds the lock until close. holdStaged yields a copy another rotation locked or replaced. The no-flock fallback is covered. - Cross-process: a helper process stages a copy and holds it. It is left alone while the helper lives; after SIGKILL it is finished. - Each new guard was mutation-checked: disabling it fails at least one test. Verified: go test (plain, and -race -short) for internal/logcap, cmd/daemon and pkg/daemon, plus ./tests shutdown and plugin subsets, on darwin. On linux/arm64 (container, root and uid 1000): internal/logcap and the cmd/daemon log tests. End to end with the real binaries: - A daemon in a fake Cellar, run through opt/ with a 55,000,000-byte var/log/pilot-daemon.log: rotated to 1,314 bytes plus .pilot.1.gz. The same log with a non-Cellar binary, and another var/log file, were untouched. - In a container: SIGKILL of a pilotctl-started daemon mid-rotation, then a second start. Pre-fix, pilot-24.log.pilot.1 (20 MB) stayed. Fixed, pilot-26.log.pilot.1 became pilot-26.log.pilot.1.gz (7,028,736 bytes uncompressed, the staged size). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ser's private group On Debian/Ubuntu and Fedora/RHEL every user has a private group and the login umask is 002, so install.sh's `mkdir -p ~/.pilot/bin` leaves ~/.pilot at 0775 with that group, and Linuxbrew's var/log likewise. checkDir refused any group write bit, so the default-on rotation there truncated the whole log and kept no backup, every time it passed 50 MB. checkDir now refuses a directory writable by others, owned by another user (root excepted), or writable by a group other than the user's own private group. A group counts as private (group_linux.go, from /etc/passwd and /etc/group) only when it is the account's primary group, carries the account's name (the test RHEL's bashrc uses to apply umask 002), no other account has it as primary group and it lists no other member, and the directory has no POSIX access ACL (with one, the group bits are the mask and a named entry may hold the write). Anything the files do not show to be that (an LDAP account, NIS compat entries, a line it cannot parse) counts as shared, which is the old behaviour. macOS has no user private groups (staff is every local account's primary group) and keeps the old check. TestMain runs the logcap tests under umask 022: under umask 002 on macOS, t.TempDir's directories are group-writable by staff and the tests unrelated to permissions failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ly pilotctl's orphaned copies Two findings from the re-review of #464, both reproduced on d19db05. LOGCAP-1: rotate() shifted the generations, dropping the oldest, before it knew the truncate would work. On a log that cannot be truncated (an append-only file under chflags uappnd or chattr +a, or a filesystem that refuses ftruncate), each round deleted one backup and copied the whole log for nothing, every minute: [1,2,3] -> [2,3] -> [3] -> []. - shiftBackups parks the oldest generation at keep+1 instead of removing it. It is dropped once the log is truncated. When the truncate fails, unstage removes the copy and moves every generation back down, so nothing moves in a round that keeps no backup. If the copy cannot be removed, generation 1 stays free for a later round to finish it into, as before. - The pre-check now covers keep+1 as well, because the shift renames onto that slot. Something that is not ours there stops the backup with the generations intact. - run() waits twice as long after each round in a row that could not truncate the log, up to 64 intervals (about an hour). One that gets through resets the wait to the interval. The loop body moved into tick() so the schedule can be tested. LOGCAP-2: finishOrphans picked up every user-owned *.pilot.1 in the log's directory by name alone. So a rotation in a shared directory such as brew's var/log, or /var/log with an explicit -log-max-size, gzipped and unlinked another program's file, for example logrotate's delaycompress generation of a log named <x>.pilot. - Orphans are finished only in a Within directory itself (~/.pilot, $PILOT_HOME/.pilot), where `pilotctl daemon start` puts its logs, and only under that command's per-daemon names, pilot-<pid>.log.pilot.1. These are the only logs that no later daemon rotates. Any other <name>.pilot.1 is left alone. Tests: the generations and the log survive repeated truncate failures, for full sets of generations and for sets with gaps, via a read-only descriptor. On darwin, the reported chflags uappnd case is covered (EPERM). unstage leaves generation 1 free for a copy it cannot remove. The retryAfter and tick backoff schedule, including the reset, is covered, and so is another user's file in the parking slot. For orphans: only pilot-<pid>.log names are taken (app.pilot.1, pilot-daemon.log, pilot-starting.log and malformed pids are not), with a positive control. Nothing is finished for an explicit rotation outside Within, for the brew service log, or in a directory below ~/.pilot. The existing orphan tests now run in a Within directory. Each new guard was mutation-checked: disabling it fails at least one test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TeoSlayer
enabled auto-merge (squash)
September 24, 2026 01:53
TeoSlayer
pushed a commit
that referenced
this pull request
Sep 24, 2026
… into feat/native-https-proxy Conflicts: - cmd/daemon/main.go: one fileConfig for both flagSources (recorded before ApplyToFlags) and #464's flagExplicit; logging.Setup stays early (the transport/proxy resolution logs), so #464's logcap.Watch moves up with it; the daemon start failure keeps #464's fatalAfterPluginStart. - cmd/daemon/shutdown.go: fatalAfterPluginStart logs at ERROR (slog), like the branch's fatalf, so pilotctl daemon start can report it. - CHANGELOG.md: both Fixed lists kept. - go.mod: tidy (golang.org/x/net indirect again, as on main, now that the branch-local refresh policy is gone). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two robustness issues found on a macOS laptop (v1.13.9, run by launchd with
KeepAlive SuccessfulExit=falseandStandardOutPath/StandardErrorPath=~/.pilot/daemon.log):os.Exit(86)directly (pkg/daemon/rxwatchdog.go), so launchd respawns the daemon.os.Exitskips the graceful shutdown incmd/daemon/main.go(d.Stop()→rt.StopPlugins()), andStopPluginsis what stops the app-store supervisor and terminates its child apps. The apps run in their own process group, so every such exit orphaned all of them: 94 copies of each of 12 apps (~2.8 GB RSS) built up in a week. The app store now reaps orphans at spawn (fix(supervisor): no orphaned app processes after a hard daemon exit app-store#38), but the daemon should not create them in the first place.daemon.loggrows forever. launchd never rotatesStandardOutPath. The file had reached 22 MB, and a June log was 13 MB gzipped.Fix
1. Watchdog exit goes through graceful shutdown (commit 1)
pkg/daemon/exit.go:requestSupervisorExit(ExitRequest{Code, Reason})hands the exit to a host handler installed withdaemon.SetExitHandler. It first arms a 15 s hard deadline that callsos.Exitwith the same code if the teardown hangs (a wedged transport can hang it). Only the first request in a process takes effect. With no handler installed (embedders, bare test daemons) it exits immediately, which is the old behavior.rxWatchdogExit(the existing test seam) now requests the graceful exit instead of callingos.Exit. Unchanged:recordRxWedgeExitstill runs before the exit, the restart-loop breaker still applies, and thetunnel.rx_wedged_exitevent is still published. The webhook plugin now gets a chance to deliver that event. After requesting the exit, the watchdog loop stops ticking, so a slow teardown can't escalate again and record a second exit against the breaker.cmd/daemon: the shutdown loop (moved intoawaitShutdown) receives exit requests alongside SIGINT/SIGTERM/SIGHUP and fleet lifecycle requests. It runs the normal teardown (Daemon.Stop→StopPlugins, same order, same reasons) and thenos.Exit(86), so launchd/systemd still see a non-zero exit and respawn.Daemon.Stopgets an 8 s budget; if it overruns, plugins are stopped anyway, so apps are still reaped before the deadline. Teardown then keeps waiting forDaemon.Stopas before.log.Fatalfcalls that run afterStartPlugins(plugin startup failure,d.Startfailure) now stop plugins before exiting 1.StartAllleaves already-started plugins running, and a respawn loop would otherwise leak a fresh set of apps on every attempt.pathwatch.goonly borrows the swappable-seam pattern and never exits. The only otheros.Exitincmd/daemon/pkg/daemonis-version.2. Built-in log size cap (commit 2)
internal/logcap: when stderr (fd 2) is a regular file, it fstats it every minute. Past the limit it rotates copy-truncate style:<path>.pilot.1O_APPEND)<path>.pilot.1into<path>.pilot.1.gz, shifting older copies up to<path>.pilot.N.gzlaunchd and
pilotctl daemon startopen the logO_APPEND, and child processes share that open file description, so truncation is safe for every writer (slog, panics, app children).The path is resolved from the fd (darwin
fcntl F_GETPATHviax/sys/unix, linuxreadlink /proc/self/fd/2) and checked against the fd's inode. An unlinked or renamed-away log is still truncated, but without a backup. Leftover.pilot.1staging files from an interrupted rotation get finished, including another log's in the same directory (commit 4), and generations beyond the configured count are pruned.It does nothing when stderr isn't a regular file (systemd/journald, terminal, pipe) or on platforms without fd→path mapping.
Scope (commits 3, 4): with the default
-log-max-size, only a log Pilot set up is rotated. That means a log inside~/.pilot(or$PILOT_HOME/.pilot), where install.sh's launchddaemon.logandpilotctl daemon start'spilot-<pid>.loglive. It also covers the Homebrew service's<brew prefix>/var/log/pilot-daemon.log, when the daemon is the formula's binary (commit 4). A log anywhere else is rotated only when-log-max-sizeis set explicitly, on the command line or inconfig.json, so operators with their own rotation are unaffected. A watched log that is moved out of scope stops being rotated.Files it didn't create are left alone (commit 3):
.pilotinfix keeps its names apart from logrotate's and newsyslog's (<log>.1,<log>.N.gz,<log>.0.gz).O_EXCL|O_NOFOLLOW, and everything it reads is openedO_NOFOLLOW./tmpincluded), is owned by another non-root user, or is writable by a group other than the user's own private group (commit 5).Flags:
-log-max-size(MB, default 50,0disables) and-log-max-backups(default 3). Becauseconfig.ApplyToFlagsmaps config keys to flags generically, both can also be set from~/.pilot/config.json(log_max_size,log_max_backups).3. Re-review fixes (commit 4)
brew serviceslog: Pilot's formula (TeoSlayer/homebrew-pilot) runs the daemon withlog_path/error_log_pathvar/"log/pilot-daemon.log". launchd, or systemd withStandardOutput=append:, opens that file and never rotates it. Newlogcap.Options.Filesputs single files in scope. A file matches by name in the same directory, and the directory is compared by identity. The daemon adds<prefix>/var/log/pilot-daemon.logonly when its executable resolves to<prefix>/Cellar/pilotprotocol/<ver>/bin/pilot-daemon. The rest ofvar/log(other formulae's logs) stays out of scope.pilotctl daemon startnames each daemon's logpilot-<pid>.log. A<log>.pilot.1left by a daemon killed mid-rotation was therefore under a name no later daemon looked at. After its own rotation, a rotation now finishes the other<log>.pilot.1copies in its directory into<log>.pilot.1.gz. It acts only on regular files of ours, and does not shift or prune that log's generations.flockon its staged copy from creation until it is compressed. The lock is held through a second descriptor, so closing the writer still reports delayed write errors before the truncate. A locked copy is skipped. Death and exec release the lock.flock, other logs' copies are skipped and the log's own rotation behaves as before.keep_alive crashed: truemeans launchd does not respawn after the watchdog's exit 86. That is pre-existing, and the fix belongs in the formula.4. Re-review fix (commit 5): user private groups
mkdir -p ~/.pilot/binthen makes~/.pilot0775with that group, and Linuxbrew'svar/logends up the same. The directory check refused any group write bit. So in both places the default rotation truncated the whole log and kept no backup, each time it passed 50 MB.checkDirnow refuses a directory that is writable by others (sticky/tmpincluded), owned by another user (root excepted), or writable by a group other users may be in. A group-writable directory is accepted only when its group is the user's own private group. On Linux (internal/logcap/group_linux.go,group.go), all of these must hold:/etc/passwd./etc/groupit has the account's name. RHEL's/etc/bashrcuses the same test to decide on umask 002. It keeps out a shared group such asusersthat the account happens to be the only member of so far./etc/grouplists no other member.+/-) entries, and lines it can't parse. Release binaries are builtCGO_ENABLED=0, so the daemon could not see NSS sources beyond these files anyway.staffis every local account's primary group, and accounts do not live in/etc/passwdor/etc/group. A group-writable directory there (umask 002 gives~/.pilot0775 user:staff) is really shared, so it still gets no backups. The default umask 022 gives0755, which keeps backups.~/.pilotas0700would fix neither existing installs nor Linuxbrew'svar/log.internal/logcaptests now run under umask 022 (TestMain). Under umask 002 on macOS,t.TempDir()directories are group-writable bystaff, and tests unrelated to permissions failed. The permission tests set their modes themselves.origin/mainis merged in with no conflicts; the CHANGELOG merged automatically.5. Re-review fixes (commit 6)
A log that can't be truncated no longer loses its backups (LOGCAP-1). The shift used to drop the oldest generation before the truncate ran. On an append-only log (
chflags uappnd,chattr +a) or a filesystem that refusesftruncate, each round deleted one backup and then threw away its copy of the whole log. That happened every minute:[1,2,3]→[2,3]→[3]→[]..pilot.<keep+1>.gz. It is dropped only after the log is truncated.keep+1, because the shift renames onto that slot. Something that isn't ours there stops the backup with the generations intact.keepremoves it.Other programs'
*.pilot.1files are left alone (LOGCAP-2). The orphan scan used to take any user-owned<x>.pilot.1in the log's directory. In a shared directory (brew'svar/log, or/var/logwith an explicit-log-max-size) it gzipped and unlinked, for example, logrotate'sdelaycompressgeneration of a log named<x>.pilot.Withindirectory itself (~/.pilot,$PILOT_HOME/.pilot). That is wherepilotctl daemon startwrites logs.pilot-<pid>.log.pilot.1, which are the only logs no later daemon rotates. A comment incmd/pilotctlpoints back to the matcher.internal/logcapis added to thelayers.yamlutilities list. CHANGELOG entries are added for both changes.Tests
pkg/daemon/zz_exit_request_test.go: an exit request reaches the handler without exiting the process; the deadline forcesexit(86)when shutdown hangs; with no handler the process exits immediately; the first request wins; the real (unswapped)rxWatchdogExitrecords the exit and then requests a graceful shutdown with code 86. The existing watchdog tests (zz_rx_watchdog*_test.go) pass unchanged.cmd/daemon/shutdown_test.go: an exit request ends the loop and runsDaemon.Stop→StopPlugins→exit(86)in that order; SIGHUP reloads, and SIGTERM tears down without a forced exit; a fleet restart maps to re-exec; plugins are still stopped whenDaemon.Stophangs.internal/logcap/logcap_test.go: size threshold (at the limit it doesn't rotate), truncate + gzip +0600mode, generation shifting and dropping the oldest, pruning when the limit is lowered,maxBackups=0, finishing an interrupted rotation, rewind for non-append writers, unlinked log, renamed log, no-op for pipe and char device, disabled at 0, backgroundWatch.internal/logcap/logcap_safety_test.go(commit 3):.1,.1.gz.tmp) and at every logcap name are not followed, and stay in place..pilot.1link to a secret file is not consumed.chownwhen run as root.Passes on darwin and on linux (arm64 container, as root and as uid 1000).
internal/logcap/logcap_interrupted_test.go(commit 4):.pilot.1, look-alike names and the parent directory.createStagedholds the lock until close.holdStagedyields a copy another rotation locked or replaced.flockfallback is covered.internal/logcap/logcap_safety_test.go(commit 4):Filesscope (the file, a symlinked dir, a neighbour, the same name elsewhere or in a subdirectory, a missing dir). A renamedFileslog stops being rotated.cmd/daemon/logrotation_test.go(commit 3):-log-max-sizecounts as explicit from the flag or fromconfig.json(either key spelling, only with a valueApplyToFlagsapplies). Default scope directories are~/.pilotand$PILOT_HOME/.pilot. Commit 4 addshomebrewServiceLog: the Cellar,optand linked-binpaths map tovar/log/pilot-daemon.log. Non-Homebrew installs, other formulae and other binaries map to nothing.GOWORK=off go build ./...(darwin, linux amd64/arm64),go veton the touched packages, andgo test ./pkg/daemon/ ./cmd/daemon/ ./internal/logcap/(also with-race -short) plus./tests -run 'TestDaemonShutdown|TestStreamLifecycleGoroutines|TestPluginShutdown'all pass. The baseline onmainwas also green.Manual smoke test: prefilled a 3 MB
daemon.log, started the daemon with-log-max-size 1 -log-max-backups 2and stderr appended to that file. The log was rotated right at startup (daemon.log.1.gz= 3,000,000 bytes uncompressed), the live log continued from 0, and the first line was thelog rotatedmarker.Smoke test after commit 3 (real binary, throwaway
HOME, over-limit logs):~/.pilot/daemon.log: rotated intodaemon.log.pilot.1.gz.~/.pilot: untouched.log_max_sizeset inconfig.json, log outside: rotated..1and.2.gz–.5.gz: rotated, and all of those files survived.~/.pilot(macOS, groupstaff): truncated, no backup, one warning.Smoke tests after commit 4:
opt/pilotprotocol, with a 55,000,000-bytevar/log/pilot-daemon.log. It was rotated to 1,314 bytes plus.pilot.1.gz(55,000,000 bytes uncompressed).var/logfile.pilotctl daemon startover a 400 MB log, SIGKILL during the first rotation, then a second start. Pre-fix,pilot-24.log.pilot.1(20 MB) was never touched. With the fix,pilot-26.log.pilot.1was finished intopilot-26.log.pilot.1.gz(7,028,736 bytes, the staged size).Every new guard in commit 4 was mutation-checked, too.
Tests for commit 5:
internal/logcap/group_test.go(all platforms) testsuserPrivateGroupdirectly.usersgroup with a single member; a group that lists another member; another account's primary group; a duplicate entry with a member; a group under a different name; a missing group or account; NIS compat lines; malformed lines; a non-numeric gid; empty files.logcap_safety_test.go:TestCheckKeepsBackupsInPrivateGroupDirectory: a0775private-group directory rotates normally. An interrupted round is finished, another log's orphaned copy is finished, and three rounds shift into.pilot.1.gz–.pilot.3.gz.TestCheckKeepsNoBackupInSharedDirectoryadds three cases: a shared group, a world-writable directory with a private group (sticky and not), and another user's directory with a private group.group_linux_test.go(Linux, real/etcfiles):TestCheckRealPrivateGroupDirectory: a directory made withmkdirunder umask 002 keeps backups.TestPrivateGroupDirWithACL: a POSIX ACL that grants another uid write makes the directory not private. The test setssystem.posix_acl_accessdirectly.TestPrivateGroupDirOtherGroup(root only): a directory given another group is refused.+line already fails the numeric parse.GOWORK=off go test ./internal/logcap/ ./cmd/daemon/ ./pkg/daemon/ -count=1passes after the merge. logcap and cmd/daemon also pass underumask 002, and logcap passes with-race.golang:1.25arm64, network off): logcap and cmd/daemon pass as root, as a private-group useruunder umask 002 (the real-file tests run), and as ausers-group uservunder umask 002 (they skip). The real-chowntest runs as root.Smoke tests after commit 5 (real binaries, default flags, 53 MB
~/.pilot/pilot-4242.log):ubuntu:24.04):su - ugets login umask0002, andmkdir -p ~/.pilot/binmakes~/.pilotdrwxrwxr-x u:u. Over two daemon starts the log was rotated into.pilot.1.gz(53,003,015 bytes) and.pilot.2.gz(53,002,267 bytes), and the live log restarted at 748 bytes. Before this commit, the log was truncated with no backup.golang:1.25:u:.pilot.1.gzwas kept.v, whose primary group isusers(0775 v:users): the log was truncated with no backup, with the WARNwritable by a group other users may be in. That is correct, sinceusersis a shared group.HOMEwith groupstaff, darwin daemon:~/.pilotis0755, and.pilot.1.gzwas kept (53,000,000 bytes).~/.pilotis0775 staff. The log was truncated with no backup, as before.Tests for commit 6:
TestCheckKeepsGenerationsWhenLogCannotBeTruncatedcovers full sets of generations and sets with gaps ({1,2,3},{1,3},{2,3},{1},{3}, none). The log is truncated through a read-only descriptor, which fails the same way on darwin and linux. After three failed rounds the directory is byte-for-byte unchanged. Once truncation works again, the rotation shifts exactly as it would have.TestCheckKeepsGenerationsOfAppendOnlyLog(darwin) is the reported case: the log is made append-only withchflags uappnd,CheckreturnserrTruncatewrappingEPERM, and every generation is kept.TestUnstageLeavesGenerationOneFreeForACopyItCannotRemove: a copy that can't be removed keeps the shift, and only the parked generation is dropped.TestRetryAfterandTestTickBacksOffWhileLogCannotBeTruncatedcheck the waits (1, 2, 4 … 64, 64 intervals), the reset after a round that gets through, and the restart at 1 after the next failure.TestCheckLeavesOtherUsersFilesAlone/in the slot the oldest is parked in: another user's file at.pilot.<keep+1>.gzstops the backup, and nothing moves.TestCheckOnlyFinishesStagedCopiesOfOursputs its link, directory and foreign-owner guard cases atpilot-<n>.log.pilot.1names, so each guard still runs. It also adds the staging names of non-pilotctllogs (app.pilot.1, i.e. the reported case, plusx.log,pilot-daemon.log,pilot-starting.log,pilot-.log,pilot-12a.log) and a positive control that is finished.TestCheckFinishesOrphansOnlyInPilotDirectory: nothing is finished for an explicit rotation outsideWithin, for the brew service log, or in a directory below~/.pilot.Withindirectory.keep+1pre-check, unshifting despite a failed remove, the drop after truncate, the backoff, its reset,tick's wait, the directory check (anywhere and nested), the name match and its prefix, suffix and digit checks, and the parked top slot.GOWORK=off go test ./internal/logcap/ ./cmd/daemon/ -count=1passes, and logcap also passes with-raceand underumask 002.go build ./...andgo vetpass for darwin and for linux amd64/arm64, and the logcap test binary compiles for linux. Linux runtime is covered by CI'subuntu-latestjob; the local Docker daemon was unresponsive this round.Related: pilot-protocol/app-store#38
🤖 Generated with Claude Code