Refuse duplicate desktop instances and re-verify backend ports on restart - #152
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
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.
Why
A user hit
ProviderAdapterProcessError: ... thread ... already has an active writermid-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/userdataSQLite state. Codex correctly rejected the second writer; the startup reaper marked the first instance's live sessions stopped; both servers clobberedserver-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.server-runtime.jsonorigin): healthy primary → silent exit 0 while the primary raises its window (second-instancehandler); unresponsive primary → blocking error dialog pointing at Task Manager.THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK=1is an operator escape hatch.Commit 2 — restart-safe port selection
Verification
vp fmt,vp lint,vp run typecheckclean; desktop suite 240/240.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)