Skip to content

feat(app): refuse a second instance so a duplicate launch is a no-op - #54

Merged
MichaelTaylor3d merged 3 commits into
mainfrom
lane/dapp-tray-open
Jul 31, 2026
Merged

feat(app): refuse a second instance so a duplicate launch is a no-op#54
MichaelTaylor3d merged 3 commits into
mainfrom
lane/dapp-tray-open

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 in main() 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 to Agent::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 at error, 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

  • Not a PID file — a crash leaves one behind and the liveness check races pid reuse. The lock is held by an open file descriptor, so the kernel releases it however the process dies.
  • Not the APP-SIGN port — the app boots with the account LOCKED (#1817), so no listener exists until the user unlocks. That is precisely the window a login autostart fires in.
  • Not a machine-global mutex — two accounts on one box are two legitimate agents, so the lock is scoped to the brand directory.
  • A duplicate exits 0, not 1 — the launchers that produce duplicates are automatic, so "already running" is the system working. An error exit would turn a healthy install red.
  • An unevaluable lock starts anyway — a host problem is not evidence of a second instance, and failing closed there leaves the user with no agent at all.

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() in crates/dig-app/src/bin/dig-app.rs — the crate's one entrypoint; no callers. The new code sits between logging::init() and Agent::from_env, adding one early-return path.
  • single_instance is a NEW module; nothing else in either crate references it.
  • libc added as a cfg(unix)-gated dep of dig-app-core. Never compiled for Windows, which uses a std-only share-mode-0 open.
  • Searched for existing instance guards before building (CreateMutex, already running, lock files, 9779 as a guard): none.
  • No public API changed, no existing symbol edited. Risk: LOW.

Verification

cargo test -p dig-app-core --locked --lib green; cargo clippy --all-targets --locked -- -D warnings clean; cargo fmt --check clean.

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:

test releasing_the_lock_lets_the_next_instance_start_even_though_the_file_remains ... FAILED

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_instances holds 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 via explorer.exe from a genuinely HIGH-integrity process:

before           : 1 instance(s), pids 74504
after 2nd launch : 1 instance(s), pids 74504
after 3rd launch : 1 instance(s), pids 74504

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:

INFO dig_app: dig-app starting version="5.3.0" os=Windows has_display=true
INFO dig_app: another dig-app is already running for this user - this launch is a no-op
             lock=C:\Users\micha\AppData\Local\DigNetwork\dig-app.lock
dig-app: already running - look for the DIG icon in your system tray.

The second process started, evaluated the lock, stood down, and exited; pid 74504 was untouched. C:\Users\micha\AppData\Local\DigNetwork\dig-app.lock was 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.

MichaelTaylor3d and others added 3 commits July 30, 2026 21:25
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 MichaelTaylor3d changed the title feat(app): refuse a second instance so a post-install launch is idempotent feat(app): refuse a second instance so a duplicate launch is a no-op Jul 31, 2026
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 31, 2026 05:23
@MichaelTaylor3d
MichaelTaylor3d merged commit b46d554 into main Jul 31, 2026
12 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the lane/dapp-tray-open branch July 31, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant