Skip to content

Make the Linux app's single-instance claim race-free and private - #455

Merged
myles332 merged 2 commits into
mainfrom
myles/linux-app-instance-fixes
Sep 28, 2026
Merged

myles332 merged 2 commits into
mainfrom
myles/linux-app-instance-fixes

Conversation

@myles332

Copy link
Copy Markdown
Contributor

Follow-up to #445, addressing Greptile's review of the Linux desktop app.

What changes

  • Single instance without a race: a launch takes a non-blocking exclusive lock (File::try_lock) on openresearch-app.lock before binding the focus socket, and keeps it until exit; files are close-on-exec, so an update's exec releases it. A launch that finds the lock held asks the running app to focus, retrying for up to 5 s while the winner binds, instead of deleting its socket. Previously two launches at once could both connect-fail, and the second would unlink the first's socket and run as a second app.
    • The lock is released if the socket can't be bound, so later launches open a window rather than waiting on nothing.
    • Where locking isn't supported, the old connect-first check still guards.
  • No shared temp directory: with XDG_RUNTIME_DIR unset (or relative), the lock and socket go in ~/.cache/openresearch instead of /tmp, where another user could create the socket first and make every launch exit. The name includes the hostname there, since a home on NFS is shared across machines.
  • Interrupted updates: the Linux updater now runs the macOS updater's sweep_leftovers before staging, removing .OpenResearch-update-* files over an hour old that a killed update left beside the AppImage.

Not changed

  • Updates through a symlinked AppImage: the runtime sets APPIMAGE from /proc/self/exe (or realpath), so it is already the target file.
  • Downloads held in memory: the macOS DMG and the CLI share fetch_release_asset; streaming it is its own change.

Test plan

  • macOS: cargo fmt --check, cargo clippy --all-targets -D warnings, cargo test --locked
  • CI: linux app (x86_64|aarch64) compiles the Linux-only code and the smoke test passes
  • Linux: launching twice quickly leaves one app, and the second launch brings its window forward
  • Linux: a second launch while the app runs still focuses it; quitting and relaunching works
  • Linux: an update restart comes back as one app

🤖 Generated with Claude Code

Take a lock file before binding the focus socket, so two launches at once
can't both become the app, and fall back to a per-user, per-host directory
instead of the shared temp dir when XDG_RUNTIME_DIR is unset. Clear an
interrupted update's staged AppImage before staging another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

[Medium risk] Refactors Linux app single-instance locking and cleanup.

The PR is not safe to merge until a shared runtime directory cannot let another user block app launches.

Findings

  1. P1 Security Shared runtime directory blocks launches ▶
  2. P2 Failed bind strands waiting launch ▶
  3. P2 Fallback failure silently disables focus ▶
  4. P2 Instance handoff lacks tests ▶
Fix with agent prompt
### Issue 1
src/commands/app.rs:365-369
If `XDG_RUNTIME_DIR` points to an absolute shared directory such as `/tmp`, another user can hold the predictable `openresearch-app.lock`. This launch then treats that lock as a running instance, waits for a socket that may not exist, and exits without opening the app. Check that the directory is private before trusting the lock. **How this was verified:** An absolute shared runtime directory reaches the predictable lock path, and a held lock causes an unanswered focus attempt followed by exit.

### Issue 2
src/commands/app.rs:394-396
If the first launch gets the lock but cannot bind its socket, it releases the lock and runs without a listener. A simultaneous launch that already found the lock held still retries only the nonexistent socket for five seconds, then exits instead of trying to claim the now-available lock. This makes that second launch appear to do nothing.

### Issue 3
src/commands/app.rs:380-382
If there is no usable `XDG_RUNTIME_DIR` and the app cannot read `/proc/sys/kernel/hostname` or resolve a cache directory, this branch runs without a listener or a diagnostic. In restricted Linux environments, later launches can therefore open separate windows instead of focusing the running app. Provide another usable claim path or report that the guard is unavailable.

### Issue 4
src/commands/app.rs:392-396
The new lock and socket handoff has no Linux instance tests for simultaneous claims, bind failure, or relaunch. Those paths determine whether a launch opens a duplicate window or exits without focusing anything, so regressions in the race fix could go unnoticed. Add focused tests with isolated claim paths and competing launches.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds a Linux file lock and focus-socket handoff, moves the fallback claim into the cache directory, and reuses the update-leftover sweeper for AppImages.

  • The runtime-directory path needs a privacy check before a held lock can prevent launch.
  • Failure paths and the new concurrency behavior need more robust handling and test coverage.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Linux app launch] --> B[Select runtime or cache paths]
  B --> C{Acquire lock?}
  C -- Yes --> D[Remove stale socket and bind]
  D -- Success --> E[Run app and handle focus requests]
  D -- Failure --> F[Run without listener; release lock]
  C -- Held --> G[Retry socket connection for up to 5 seconds]
  G -- Connected --> H[Exit after focus request]
  G -- Unanswered --> I[Exit without reclaiming lock]
  C -- Error --> J[Try socket-only fallback]
Loading

Reviews (1) · Last reviewed commit: "Make the Linux app's single-instance cla..."

Comment thread src/commands/app.rs
Comment on lines +365 to +369
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
// SAFETY: getuid has no failure mode.
dir.join(format!("openresearch-app-{}.sock", unsafe {
libc::getuid()
}))
.filter(|dir| dir.is_absolute());
if let Some(dir) = runtime {
return Some((dir, "openresearch-app".to_string()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Shared runtime directory blocks launches If XDG_RUNTIME_DIR points to an absolute shared directory such as /tmp, another user can hold the predictable openresearch-app.lock. This launch then treats that lock as a running instance, waits for a socket that may not exist, and exits without opening the app. Check that the directory is private before trusting the lock. How this was verified: An absolute shared runtime directory reaches the predictable lock path, and a held lock causes an unanswered focus attempt followed by exit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 365-369

Comment:
**Shared runtime directory blocks launches** If `XDG_RUNTIME_DIR` points to an absolute shared directory such as `/tmp`, another user can hold the predictable `openresearch-app.lock`. This launch then treats that lock as a running instance, waits for a socket that may not exist, and exits without opening the app. Check that the directory is private before trusting the lock. **How this was verified:** An absolute shared runtime directory reaches the predictable lock path, and a held lock causes an unanswered focus attempt followed by exit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/commands/app.rs Outdated
Comment on lines +394 to +396
Ok((Err(TryLockError::WouldBlock), _)) => {
ask_running_app_to_focus(&socket);
return None;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed bind strands waiting launch If the first launch gets the lock but cannot bind its socket, it releases the lock and runs without a listener. A simultaneous launch that already found the lock held still retries only the nonexistent socket for five seconds, then exits instead of trying to claim the now-available lock. This makes that second launch appear to do nothing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 394-396

Comment:
**Failed bind strands waiting launch** If the first launch gets the lock but cannot bind its socket, it releases the lock and runs without a listener. A simultaneous launch that already found the lock held still retries only the nonexistent socket for five seconds, then exits instead of trying to claim the now-available lock. This makes that second launch appear to do nothing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/commands/app.rs
Comment on lines +380 to +382
let Some((dir, name)) = claim_paths() else {
return Some(FocusRequests(None));
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fallback failure silently disables focus If there is no usable XDG_RUNTIME_DIR and the app cannot read /proc/sys/kernel/hostname or resolve a cache directory, this branch runs without a listener or a diagnostic. In restricted Linux environments, later launches can therefore open separate windows instead of focusing the running app. Provide another usable claim path or report that the guard is unavailable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 380-382

Comment:
**Fallback failure silently disables focus** If there is no usable `XDG_RUNTIME_DIR` and the app cannot read `/proc/sys/kernel/hostname` or resolve a cache directory, this branch runs without a listener or a diagnostic. In restricted Linux environments, later launches can therefore open separate windows instead of focusing the running app. Provide another usable claim path or report that the guard is unavailable.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/commands/app.rs Outdated
Comment on lines +392 to +396
let lock = match lock.map(|file| (file.try_lock(), file)) {
Ok((Ok(()), file)) => Some(file),
Ok((Err(TryLockError::WouldBlock), _)) => {
ask_running_app_to_focus(&socket);
return None;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Instance handoff lacks tests The new lock and socket handoff has no Linux instance tests for simultaneous claims, bind failure, or relaunch. Those paths determine whether a launch opens a duplicate window or exits without focusing anything, so regressions in the race fix could go unnoticed. Add focused tests with isolated claim paths and competing launches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 392-396

Comment:
**Instance handoff lacks tests** The new lock and socket handoff has no Linux instance tests for simultaneous claims, bind failure, or relaunch. Those paths determine whether a launch opens a duplicate window or exits without focusing anything, so regressions in the race fix could go unnoticed. Add focused tests with isolated claim paths and competing launches.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…ed lock

Use XDG_RUNTIME_DIR only when the user owns it and no one else can write it,
retry the lock alongside the socket so a launch waiting on an app that gave
the lock up takes over, say when no private directory exists, and test the
handoff in a scratch directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@myles332
myles332 merged commit 89f2849 into main Sep 28, 2026
15 checks passed
@myles332
myles332 deleted the myles/linux-app-instance-fixes branch September 28, 2026 21:04
@myles332 myles332 mentioned this pull request Sep 28, 2026
2 of 4 tasks
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