fix(daemon): compare daemon paths by file identity, not string#289
Conversation
`daemon install`/`reinstall` resolved the daemon binary and, when the resolved path differed from ~/.shelltime/bin/shelltime-daemon, renamed that curl-installer copy to .bak and pointed the service at the resolved path. The comparison was a plain string compare, so when the resolved path was merely an alias of the curl binary (a symlink in /usr/local/bin, a symlinked $HOME, or a realpath'd PATH entry) the only real binary was moved to .bak and the service pointed at a now-dangling path. The daemon then failed to start, and every reinstall re-hid the binary — leaving ~/.shelltime/bin with shelltime-daemon.bak but no shelltime-daemon, with no way to recover via install/reinstall. Compare by device+inode (os.SameFile) instead: in ResolveDaemonBinaryPath a PATH/Homebrew candidate that is the same file as the curl binary now falls through to return the canonical curl path, and the .bak rename only fires for a genuinely distinct file. Stuck machines self-heal on the next install (the existing .bak restore runs, then the binary is no longer re-hidden). Real distinct Homebrew/system installs still win, preserving the "brew drives the daemon" behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Pjkb6uP7ttH8ogFQspMQY
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Codecov Report❌ Patch coverage is
❌ Your project check has failed because the head coverage (78.73%) is below the target coverage (80.00%). You can increase the head coverage or adjust the target coverage.
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request resolves an installation loop issue on Linux by ensuring that symlinks or aliases pointing back to the curl-installer binary are not treated as distinct system installations. It introduces a sameFile helper using os.SameFile to correctly identify identical on-disk files. The review feedback suggests a minor optimization to sameFile to return early if the two path strings are identical, avoiding redundant os.Stat calls.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| func sameFile(a, b string) bool { | ||
| ai, err := os.Stat(a) |
There was a problem hiding this comment.
An optimization and robustness improvement can be made by checking if the two path strings are identical (a == b) at the very beginning of sameFile. If they are identical, they are guaranteed to refer to the same file path, and we can return true immediately. This avoids two redundant os.Stat system calls and ensures correct behavior even if the file is temporarily inaccessible or does not exist.
| func sameFile(a, b string) bool { | |
| ai, err := os.Stat(a) | |
| func sameFile(a, b string) bool { | |
| if a == b { | |
| return true | |
| } | |
| ai, err := os.Stat(a) |
|
@claude ultrareview |
|
Claude finished @AnnatarHe's task in 1m 52s —— View job Ultra Review
SummaryThis is a well-targeted, minimal fix for a genuine data-loss bug. The root cause analysis in the PR description is accurate and the approach — replacing string equality with Verdict: Approve with minor suggestions. Core Logic (
|
Problem
On Linux, running
shelltime daemon reinstall(ordaemon install) could leave~/.shelltime/bin/withshelltimeandshelltime-daemon.bakbut noshelltime-daemon. The daemon then can't start, and re-running install/reinstall never recovers — it stays stuck. The.bakfile holds the correct, working daemon version. Becauseinstall.bashrunsshelltime daemon reinstallon every upgrade, this path is hit routinely.Root cause
Two pieces of code interacted destructively:
model/path.go → ResolveDaemonBinaryPath()prefers a binary onPATH/ in/opt/homebrew/bin,/usr/local/bin,/home/linuxbrew/.linuxbrew/binover the curl-installer copy at~/.shelltime/bin/shelltime-daemon. It decided "this is a different binary" with a string comparison (resolved != curlPath).commands/daemon.install.gothen renamedcurlPath → curlPath+".bak"whenever the resolved path string differed, and pointed the systemd/launchd service at the resolved path.When the same on-disk file was reachable via a different path string — a symlink in
/usr/local/binpointing back at the curl binary, a symlinked$HOME, or arealpath'dPATHentry — the string compare wrongly reported "different". The only real binary was moved to.bakand the service was pointed at a now-dangling alias path, so the daemon failed to launch. On the next reinstall the restore block renamed.bak → shelltime-daemon, but resolution again returned the alias string and the backup block re-hid it → infinite stuck loop.commands/daemon.install.goandmodel/path.goare platform-agnostic, so the same bug existed on macOS.Fix
Compare daemon paths by file identity (
os.SameFile, i.e. device+inode) instead of string equality, at both layers:ResolveDaemonBinaryPath(): aPATH/Homebrew candidate that is the same file as the curl binary now falls through and returns the canonical curl path (guarded in both theLookPathbranch and the Homebrew-search loop, sincePATHis often stripped under systemd/launchd).commands/daemon.install.go: a newshouldPreserveCurlDaemonhelper only renames the curl copy to.bakfor a genuinely distinct file — never the binary the service is about to point at.os.SameFileis correct across symlinks, hardlinks, bind mounts and case-insensitive filesystems, and returnsfalseacross different filesystems (no inode collisions). It mirrors the existing symlink-aware handling inResolveCLIBinaryPath(model/updater.go).Recovery is automatic
On a currently-stuck machine, the next
daemon install/reinstallfirst restores.bak → shelltime-daemon, resolution now recognizes the alias and returns the curl path, and the backup block no longer re-hides it — no manual cleanup, no GitHub re-download. Real, distinct Homebrew/system installs still win, preserving the intended "brew drives the daemon" behavior.Out of scope
Runtime verification of the resolved binary (running
shelltime-daemon -vbefore preferring it) would additionally cover a genuinely-distinct-but-broken system binary. It's intentionally excluded here (different failure mode; adds risk in sandboxed/noexec/slow environments) and can be a separate change.Tests
model/path_test.go: new subtests for a PATH symlink and a Homebrew-search symlink that point back at the curl binary (both fail before the fix, pass after); aTest_sameFiletable test (same path, symlink↔target, distinct files, missing operands, broken symlink). The existing "prefers a distinct PATH binary" case still passes, guarding against over-correction.commands/daemon.install_test.go(new): table test forshouldPreserveCurlDaemon— identical strings → don't preserve; distinct files → preserve; symlink alias of curl → don't preserve.Verification run
go build ./...✅,go vet ./...✅ (one pre-existing unrelated warning incmd/cli/main.go)go test ./model/ ./commands/ ./stloader/✅daemon(TestGitInfoTestSuite), confirmed pre-existing and environmental — it fails identically on a clean tree without these changes (git branch detection returns empty in the sandbox).🤖 Generated with Claude Code
https://claude.ai/code/session_016Pjkb6uP7ttH8ogFQspMQY
Generated by Claude Code