feat(app): refuse a second instance so a duplicate launch is a no-op - #54
Merged
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
dig-app had no single-instance guard at all. Three launchers start it without anyone asking — the installer when it finishes (dig_ecosystem#1831), the OS at login, and a user who cannot see a tray icon and double-clicks the binary — so a second copy simply ran: two tray icons, two agents over one profile directory, and a second APP-SIGN server that lost the race for port 9779, logged the loss at error, and stayed alive. That is an agent that looks installed and is not. The guard is an OS lock on <brand_dir>/dig-app.lock, held by an open file descriptor so the kernel releases it however the process dies. A PID file would need reaping and would race pid reuse; the APP-SIGN port cannot answer the question because the app boots with the account locked (#1817), so no listener exists during exactly the window a login autostart fires in; and a machine-global mutex would be too exclusive, since two accounts on one box are two legitimate agents. A duplicate launch exits 0. These launchers are automatic, so "already running" is the system working, not a fault, and an error exit would turn a healthy install red. An unresolvable or unevaluable lock starts anyway rather than standing down: that is a host problem, not evidence of a second instance, and failing closed there would leave the user with no agent at all. Co-Authored-By: Claude <noreply@anthropic.com>
A written autostart artifact starts nothing on its own: the module that renders the macOS/Linux artifacts here is a library surface nothing in this crate calls, and a written-but-unloaded systemd user unit is inactive and disabled. SPEC now says so, and points at the installer step that loads it. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
marked this pull request as ready for review
July 31, 2026 05:23
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.
Closes part of DIG-Network/dig_ecosystem#1831 (the dig-app half; the installer half is DIG-Network/dig-installer#50).
What changed
dig_app_core::single_instance— a per-user OS lock on<brand_dir>/dig-app.lock, taken inmain()before the agent starts. A duplicate launch exits 0 as a no-op.dig-app had no single-instance guard at all. The ticket assumed one existed.
main()went straight from argv parsing toAgent::from_env, so a second copy simply ran: two tray icons, two agents over one profile directory, and a second APP-SIGN server that lost the race for port 9779, logged the loss aterror, and stayed alive. That is an agent that looks installed and is not — and it is the state an install-completion launch would have produced on any machine where dig-app was already running.Design notes
Blast radius checked
gitnexus is disabled for the dev loop (CLAUDE.md §2.0 temp override), so this was done by ripgrep + direct code read.
main()incrates/dig-app/src/bin/dig-app.rs— the crate's one entrypoint; no callers. The new code sits betweenlogging::init()andAgent::from_env, adding one early-return path.single_instanceis a NEW module; nothing else in either crate references it.libcadded as acfg(unix)-gated dep ofdig-app-core. Never compiled for Windows, which uses a std-only share-mode-0 open.CreateMutex,already running, lock files,9779as a guard): none.Verification
cargo test -p dig-app-core --locked --libgreen;cargo clippy --all-targets --locked -- -D warningsclean;cargo fmt --checkclean.The tests are proven load-bearing. Swapping in the nearest wrong implementation — a file-exists check — passes 4 of the 5 and fails exactly one:
That is the staleness property: the lock file deliberately outlives the lock (asserted, so the fixture cannot be satisfied by an implementation that deletes it and calls that a release), and the next instance must still start. A user whose dig-app was killed must not be locked out until they find and delete a file.
two_brand_directories_are_two_independent_instancesholds both locks simultaneously — the only arrangement that can distinguish a per-user lock from a machine-global mutex.End-to-end evidence (Windows)
Real
dig-app.exe, launched three times viaexplorer.exefrom a genuinely HIGH-integrity process:The control that matters: "one process" would also be the reading if the launcher had simply never started a second one. It did. Running a second copy in the foreground:
The second process started, evaluated the lock, stood down, and exited; pid 74504 was untouched.
C:\Users\micha\AppData\Local\DigNetwork\dig-app.lockwas present, timestamped to 74504's start.Version
5.3.0 -> 5.4.0. Minor: a new behaviour (a second launch now declines) with no removed or changed API.