Skip to content

Refuse duplicate desktop instances and re-verify backend ports on restart - #152

Merged
badcuban merged 3 commits into
mainfrom
single-instance-lock
Aug 14, 2026
Merged

Refuse duplicate desktop instances and re-verify backend ports on restart#152
badcuban merged 3 commits into
mainfrom
single-instance-lock

Conversation

@badcuban

Copy link
Copy Markdown
Collaborator

Why

A user hit ProviderAdapterProcessError: ... thread ... already has an active writer mid-turn. Investigation found two release desktop processes running against the same profile: an older updater-relaunched process still held port 3773 and the Codex thread, while a newer launch port-scanned to 3774 and ran a full duplicate stack on the same ~/.threadlines/userdata SQLite state. Codex correctly rejected the second writer; the startup reaper marked the first instance's live sessions stopped; both servers clobbered server-runtime.json.

Nothing prevented this: the desktop never took Electron's single-instance lock, and the port scan treats an occupied 3773 as "pick 3774" rather than "another Threadlines owns this profile". The main window also only appears after backend readiness, so a slow cold start actively invites the second click. (Upstream T3 Code has been single-instance since its Clerk bridge migration; the fork lost the lock as a side effect of removing Clerk.)

What

Commit 1 — single-instance lock (release lane only)

  • app.requestSingleInstanceLock() immediately after userData is final, before any layer is built. A denied launch never spawns a backend, binds a port, or opens SQLite.
  • Denied launch probes the primary's readiness endpoint (via the server-runtime.json origin): healthy primary → silent exit 0 while the primary raises its window (second-instance handler); unresponsive primary → blocking error dialog pointing at Task Manager.
  • 15s shutdown failsafe on all quit paths: a hung teardown force-exits instead of leaving an invisible process holding the lock, the port, and the provider threads (the likely genesis of the original incident).
  • Dev launches exempt; THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK=1 is an operator escape hatch.

Commit 2 — restart-safe port selection

  • The stored backend port is re-probed on every (re)start; a taken scan-selected port triggers a rescan and an exposure-state rewrite so port, provenance, and advertised URLs stay in agreement. Explicitly configured ports are never moved. An open window is re-pointed when the backend comes back on a new origin.
  • Fixes a pre-existing bug surfaced during verification: a port stolen while the backend was down put the restart loop into retrying the dead port forever.

Verification

  • vp fmt, vp lint, vp run typecheck clean; desktop suite 240/240.
  • Two rounds of live multi-process sandbox testing on Windows (isolated profiles/state, packaged-style launches): duplicate denied with no backend/port/SQLite touched, denial during cold start, distinct profiles coexisting, kill-switch honored, stale-lock recovery after taskkill /F, clean quit leaving zero processes, concurrent-launch port race provoked and self-healed (loser moved 3774→3775, window followed), port-theft-while-down recovered with window re-point, healthy-primary silent handoff (exit 0, no dialog).

Rollout

Nightly first, stable after soak. Denied launches and port moves emit distinct log lines for monitoring.

Follow-ups (not in this PR)

  • Windows shows the denial dialog under the generic "Error" caption (Electron limitation for pre-ready dialogs); cosmetic.
  • CI-automate the hung-primary scenario (suspend primary, assert denial).
  • Real NSIS stable↔nightly update handover test in CI.

Two release-profile desktop processes share one Electron profile, one
~/.threadlines/userdata state directory, one SQLite database, and the
same provider threads. A second launch used to port-scan past the
running backend and build a full duplicate stack against that shared
state: Codex rejected the second writer mid-turn, the startup reaper
marked the first instance's live sessions stopped, and both servers
fought over server-runtime.json. The window also only appears once the
backend is ready, so a slow cold start invites the second click that
triggers all of this.

Request Electron's single-instance lock immediately after userData is
final (the lock is scoped to that path), before any layer is built. A
denied launch probes the primary's readiness endpoint via the
server-runtime.json origin: a healthy primary means a silent exit while
the primary raises its window (second-instance handler); an unresponsive
primary gets a blocking error dialog pointing at Task Manager, the one
case where waiting on the user is right.

Quits now carry a 15s failsafe that force-exits when shutdown hangs, so
a wedged teardown can no longer leave an invisible process holding the
lock, the port, and the provider threads.

Development launches are exempt (they have their own userData and run
next to release installs by design), and
THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK=1 skips the lock entirely.
The backend port was probed once at bootstrap and then treated as
settled: if the port was taken while the backend was down (or two
instances raced the same probe window), the restart loop retried the
dead port forever at max backoff. Observed live with two isolated
profiles launched in the same instant, both selecting 3774.

Port-scan logic moves to backend/backendPort.ts, shared by bootstrap and
restart. DesktopBackendConfiguration.resolve now re-probes the stored
port before every start and, when a scan-selected port was taken,
rescans and rewrites the exposure state so the port, its provenance, and
the advertised URLs stay in agreement. Explicitly configured ports are
never moved; their bind failures stay visible. When the backend does
come back elsewhere, an open main window is re-pointed at the new origin
instead of stranding on the dead one.
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
threadlines Skipped Skipped Aug 14, 2026 6:47am

Request Review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Aug 14, 2026
@badcuban
badcuban merged commit 78c5d19 into main Aug 14, 2026
13 checks passed
@badcuban
badcuban deleted the single-instance-lock branch August 14, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant