Skip to content

fix(deeplink): the file lock ate deep links while the app was running — relay them - #492

Merged
runyourempire merged 1 commit into
mainfrom
fix/deeplink-warm-relay
Aug 18, 2026
Merged

fix(deeplink): the file lock ate deep links while the app was running — relay them#492
runyourempire merged 1 commit into
mainfrom
fix/deeplink-warm-relay

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

The file lock ate deep links while the app was running

Found during live end-to-end verification of #491 on the canonical layout. With 4DA running, "Open in 4DA" spawns a second process carrying the fourda:// URL in argv — and the pre-Tauri single-instance file lock exits it long before tauri_plugin_single_instance (the designed warm-path forwarder, wired up in #491) ever initializes. The URL was dropped silently; the app log showed no "Deep-link received".

The isolated-worktree test in #491's verification had passed only by accident of layout: its two processes resolved different data dirs, so the file lock never collided and the plugin got its chance. In production both processes resolve the same data dir — so every warm deep link died at the lock. (The engine coexists by design: initialize_pre_tauri(false) skips the lock, so the lock holder is always a GUI instance.)

Fix: relay through the data dir, keep the lock's guarantee

The lock exists to stop a second process reaching the SQLite WAL — that property is untouched. The rejected instance now validates the URL, parks it in data_dir/.deeplink-relay (atomic temp+rename, timestamped), and exits without touching the database. The primary polls the path once per second: consume-at-most-once, stale entries (>60s) discarded so a crashed primary can't replay an old activation, window brought to front the way the plugin callback would have, then emitted through the same validated deep-link-activate path.

The plugin's own forwarding stays registered — it still covers the soft-fail case where the file lock errors on I/O and the second instance reaches Tauri.

Verification

  • 8/8 single_instance tests including 3 new relay tests (roundtrip + consume-once, stale discard, garbage tolerance)
  • clippy clean on the plain lib target; file-size and ghost gates pass
  • Post-merge plan: rebuild root binaries, live-fire fourda://activate?key=<real key> against the running canonical app — the exact scenario that failed — and confirm activate_license success via Victauri IPC trace before calling the feature production-ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN

… — relay them

Found live during end-to-end verification of #491 on the canonical layout:
with 4DA running, "Open in 4DA" spawned a second instance that died at the
pre-Tauri single-instance file lock — long before tauri_plugin_single_instance
(whose argv forwarding is the designed warm-path delivery) ever initializes.
The URL went nowhere. The isolated worktree test had passed only because its
two processes resolved DIFFERENT data dirs, so the file lock never collided;
in production both resolve the same dir, so EVERY warm deep link was dropped.

The relay keeps the lock's WAL-corruption guarantee intact: the rejected
instance validates the URL, parks it in data_dir/.deeplink-relay (atomic
temp+rename, timestamped), and exits without touching the database. The
primary polls the path once a second, consumes at most once, discards stale
entries (>60s — a crashed primary must not replay an old activation), brings
the window to front the way the plugin callback would have, and emits through
the same validated deep-link path the event listener uses.

The plugin's own forwarding stays registered — it still covers the case where
the file lock soft-fails on I/O error and the second instance reaches Tauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN
@runyourempire
runyourempire enabled auto-merge (squash) August 18, 2026 15:15
@runyourempire
runyourempire merged commit f687c8c into main Aug 18, 2026
14 checks passed
@runyourempire
runyourempire deleted the fix/deeplink-warm-relay branch August 18, 2026 15:29
runyourempire added a commit that referenced this pull request Aug 18, 2026
…er land in a windowless app (#493)

## The relay delivered — into a windowless app

Operator's live repro (screenshots, 2026-08-19): Chrome's protocol
dialog fired, the secondary's console flashed, then nothing. The
primary's log shows `Deep-link received via relay` at 15:50:31 — the
#492 handoff worked — but the **main window had been destroyed**, so
`get_webview_window("main")` was `None` and the emit went nowhere.
Window introspection confirmed only the hidden briefing window remained.

The killer left no trace: no `CloseRequested` (hide-to-tray never
logged), no app-side `destroy()` caller exists for main (only
briefing/notification recreate themselves), and the notification window
died the same way minutes earlier ("JS never loaded — recreating") —
suspected WebView2-level death. Whatever kills it, nothing in the app
could ever bring main back: deep links, tray **Show 4DA**, and tray
left-click all silently no-op until an app restart.

## Fix: `ensure_main_window()` — return main, or rebuild it from its
config

- **Deep-link delivery** (plugin event listener + relay poll, unified in
`deliver_deep_link`): window present → front it and emit; window gone →
**park the URL via the cold-start pending mechanism and recreate the
window** — the fresh frontend collects the parked URL on mount via
`take_pending_deep_link`, so no emit races the webview boot.
- **All three tray show paths** use the same primitive — a destroyed
window is no longer unrecoverable.
- **Forensic hook**: `WindowEvent::Destroyed` is now logged with its
label, so the next silent window death names its moment.

## Second bug from the same trace: the rejected instance opened the
shared DB

The secondary's argv scan ran `validate_deep_link_url`, whose rejection
path writes a security event via `get_database()` — the operator's
console shows the REJECTED second instance logging `Initializing
database ... 4da.db` (argv[0], the exe path, always fails validation → a
bogus "rejected deep-link" security event on every relay). That is the
exact WAL hazard the pre-Tauri lock exists to prevent. The scan is now a
bare scheme-prefix check on `args().skip(1)` with zero DB contact; the
primary validates on receipt, where it always did.

## Verification

- clippy `-D warnings` clean on the plain lib target; `single_instance`
8/8, `utils::url` 20/20; size + doc gates pass.
- Post-merge live plan (canonical layout, real key): (1) warm path with
window present; (2) **destroy the main window, fire the deep link, watch
it recreate and activate** — the operator's exact failure; (3) secondary
console shows no DB init and no bogus security event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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