Skip to content

Commit 124adf6

Browse files
committed
Improve account switching reliability and UX
1 parent 56eefc3 commit 124adf6

10 files changed

Lines changed: 433 additions & 59 deletions

File tree

src-tauri/src/auth/fs_utils.rs

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::ffi::OsString;
22
use std::fs::{self, File, OpenOptions};
3+
use std::io::Read;
34
use std::io::Write;
45
use std::path::{Path, PathBuf};
6+
use std::process::{Command, Stdio};
57
use std::thread;
68
use std::time::{Duration, SystemTime, UNIX_EPOCH};
79

@@ -32,6 +34,7 @@ impl FileLock {
3234
return Ok(Self { path: lock_path });
3335
}
3436
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
37+
maybe_clear_stale_lock(&lock_path);
3538
thread::sleep(Duration::from_millis(50));
3639
}
3740
Err(err) => {
@@ -42,8 +45,8 @@ impl FileLock {
4245
}
4346
}
4447

45-
anyhow::bail!("Timed out waiting for file lock: {}", lock_path.display());
46-
}
48+
anyhow::bail!("Timed out waiting for file lock: {}", lock_path.display());
49+
}
4750
}
4851

4952
impl Drop for FileLock {
@@ -106,3 +109,91 @@ fn temp_path_for(path: &Path) -> PathBuf {
106109
.unwrap_or(0);
107110
sibling_with_suffix(path, &format!(".tmp-{}-{nanos}", std::process::id()))
108111
}
112+
113+
fn maybe_clear_stale_lock(lock_path: &Path) {
114+
let Ok(Some(pid)) = read_lock_pid(lock_path) else {
115+
return;
116+
};
117+
118+
if !process_is_alive(pid) {
119+
let _ = fs::remove_file(lock_path);
120+
}
121+
}
122+
123+
fn read_lock_pid(lock_path: &Path) -> Result<Option<u32>> {
124+
let mut content = String::new();
125+
File::open(lock_path)
126+
.with_context(|| format!("Failed to open lock file: {}", lock_path.display()))?
127+
.read_to_string(&mut content)
128+
.with_context(|| format!("Failed to read lock file: {}", lock_path.display()))?;
129+
130+
let trimmed = content.trim();
131+
if trimmed.is_empty() {
132+
return Ok(None);
133+
}
134+
135+
let pid = trimmed
136+
.parse::<u32>()
137+
.with_context(|| format!("Invalid PID in lock file: {}", lock_path.display()))?;
138+
Ok(Some(pid))
139+
}
140+
141+
#[cfg(unix)]
142+
fn process_is_alive(pid: u32) -> bool {
143+
Command::new("kill")
144+
.args(["-0", &pid.to_string()])
145+
.stdout(Stdio::null())
146+
.stderr(Stdio::null())
147+
.status()
148+
.map(|status| status.success())
149+
.unwrap_or(false)
150+
}
151+
152+
#[cfg(windows)]
153+
fn process_is_alive(pid: u32) -> bool {
154+
Command::new("tasklist")
155+
.args(["/FI", &format!("PID eq {pid}")])
156+
.stdout(Stdio::piped())
157+
.stderr(Stdio::null())
158+
.output()
159+
.map(|output| {
160+
let stdout = String::from_utf8_lossy(&output.stdout);
161+
stdout.lines().any(|line| line.contains(&pid.to_string()))
162+
})
163+
.unwrap_or(false)
164+
}
165+
166+
#[cfg(test)]
167+
mod tests {
168+
use super::*;
169+
170+
fn test_dir() -> PathBuf {
171+
let path = std::env::temp_dir().join(format!(
172+
"codex-switcher-fs-utils-{}-{}",
173+
std::process::id(),
174+
SystemTime::now()
175+
.duration_since(UNIX_EPOCH)
176+
.map(|duration| duration.as_nanos())
177+
.unwrap_or(0)
178+
));
179+
fs::create_dir_all(&path).unwrap();
180+
path
181+
}
182+
183+
#[test]
184+
fn acquire_reclaims_stale_lock_file() {
185+
let dir = test_dir();
186+
let target = dir.join("auth.json");
187+
let lock_path = sibling_with_suffix(&target, ".lock");
188+
fs::write(&lock_path, format!("{}\n", u32::MAX)).unwrap();
189+
190+
let lock = FileLock::acquire(&target).expect("stale lock should be reclaimed");
191+
let content = fs::read_to_string(&lock_path).unwrap();
192+
193+
assert_eq!(content.trim(), std::process::id().to_string());
194+
195+
drop(lock);
196+
assert!(!lock_path.exists());
197+
fs::remove_dir_all(dir).unwrap();
198+
}
199+
}

src-tauri/src/commands/account.rs

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ const MAX_IMPORT_JSON_BYTES: u64 = 2 * 1024 * 1024;
5555
const MAX_IMPORT_FILE_BYTES: u64 = 8 * 1024 * 1024;
5656
const SLIM_IMPORT_CONCURRENCY: usize = 6;
5757

58+
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
59+
#[serde(rename_all = "snake_case")]
60+
pub enum SwitchAccountMode {
61+
#[default]
62+
RequireRestart,
63+
KeepRunning,
64+
RestartRunning,
65+
}
66+
5867
#[derive(Debug, serde::Serialize, serde::Deserialize)]
5968
struct SlimPayload {
6069
#[serde(rename = "v")]
@@ -124,7 +133,7 @@ pub async fn add_account_from_file(path: String, name: String) -> Result<Account
124133
#[tauri::command]
125134
pub async fn switch_account(
126135
account_id: String,
127-
restart_running_codex: Option<bool>,
136+
switch_mode: Option<SwitchAccountMode>,
128137
) -> Result<(), String> {
129138
let store = load_accounts().map_err(|e| e.to_string())?;
130139
let running_processes = collect_running_codex_processes().map_err(|e| e.to_string())?;
@@ -136,12 +145,8 @@ pub async fn switch_account(
136145
.find(|a| a.id == account_id)
137146
.ok_or_else(|| format!("Account not found: {account_id}"))?;
138147

139-
let should_restart = restart_running_codex.unwrap_or(false);
140-
if !running_processes.is_empty() && !should_restart {
141-
return Err(String::from(
142-
"Codex is currently running. Confirm a graceful restart before switching accounts.",
143-
));
144-
}
148+
let should_restart =
149+
resolve_switch_behavior(!running_processes.is_empty(), switch_mode.unwrap_or_default())?;
145150

146151
if should_restart {
147152
gracefully_stop_codex_processes(&running_processes).map_err(|e| e.to_string())?;
@@ -158,6 +163,50 @@ pub async fn switch_account(
158163
Ok(())
159164
}
160165

166+
fn resolve_switch_behavior(
167+
has_running_processes: bool,
168+
switch_mode: SwitchAccountMode,
169+
) -> Result<bool, String> {
170+
if !has_running_processes {
171+
return Ok(false);
172+
}
173+
174+
match switch_mode {
175+
SwitchAccountMode::RequireRestart => Err(String::from(
176+
"Codex is currently running. Choose whether to keep current sessions running or restart them before switching accounts.",
177+
)),
178+
SwitchAccountMode::KeepRunning => Ok(false),
179+
SwitchAccountMode::RestartRunning => Ok(true),
180+
}
181+
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use super::*;
186+
187+
#[test]
188+
fn keep_running_mode_allows_switch_with_running_processes() {
189+
let should_restart = resolve_switch_behavior(true, SwitchAccountMode::KeepRunning).unwrap();
190+
191+
assert!(!should_restart);
192+
}
193+
194+
#[test]
195+
fn restart_running_mode_requests_restart_when_processes_are_running() {
196+
let should_restart =
197+
resolve_switch_behavior(true, SwitchAccountMode::RestartRunning).unwrap();
198+
199+
assert!(should_restart);
200+
}
201+
202+
#[test]
203+
fn missing_mode_requires_explicit_choice_when_processes_are_running() {
204+
let error = resolve_switch_behavior(true, SwitchAccountMode::RequireRestart).unwrap_err();
205+
206+
assert!(error.contains("Choose whether to keep current sessions running"));
207+
}
208+
}
209+
161210
#[tauri::command]
162211
pub async fn get_app_settings() -> Result<AppSettings, String> {
163212
load_settings().map_err(|e| e.to_string())

src-tauri/src/commands/process.rs

Lines changed: 94 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ pub fn gracefully_stop_codex_processes(processes: &[RunningCodexProcess]) -> any
107107
return Ok(());
108108
}
109109

110-
anyhow::bail!("Timed out waiting for Codex processes to close gracefully");
110+
anyhow::bail!(format_graceful_shutdown_timeout(processes));
111111
}
112112

113113
pub fn restart_codex_processes(processes: &[RunningCodexProcess]) -> anyhow::Result<()> {
@@ -157,40 +157,67 @@ fn collect_running_codex_processes_unix() -> anyhow::Result<Vec<RunningCodexProc
157157
let stdout = String::from_utf8_lossy(&output.stdout);
158158

159159
for line in stdout.lines() {
160-
let line = line.trim();
161-
if line.is_empty() {
162-
continue;
163-
}
164-
165-
if let Some((pid_str, command)) = line.split_once(' ') {
166-
let command = command.trim();
167-
let executable = command.split_whitespace().next().unwrap_or("");
168-
let is_codex = executable == "codex" || executable.ends_with("/codex");
169-
let is_background = command.contains(".antigravity")
170-
|| command.contains("openai.chatgpt")
171-
|| command.contains(".vscode");
172-
let is_switcher =
173-
command.contains("codex-switcher") || command.contains("Codex Switcher");
174-
175-
if is_codex && !is_switcher {
176-
if let Ok(pid) = pid_str.trim().parse::<u32>() {
177-
if pid != std::process::id()
178-
&& !processes.iter().any(|p: &RunningCodexProcess| p.pid == pid)
179-
{
180-
processes.push(RunningCodexProcess {
181-
pid,
182-
command: command.to_string(),
183-
is_background,
184-
});
185-
}
186-
}
160+
if let Some(process) = parse_unix_process_line(line) {
161+
if process.pid != std::process::id()
162+
&& !processes.iter().any(|p: &RunningCodexProcess| p.pid == process.pid)
163+
{
164+
processes.push(process);
187165
}
188166
}
189167
}
190168

191169
Ok(processes)
192170
}
193171

172+
#[cfg(unix)]
173+
fn parse_unix_process_line(line: &str) -> Option<RunningCodexProcess> {
174+
let line = line.trim();
175+
if line.is_empty() {
176+
return None;
177+
}
178+
179+
let (pid_str, command) = line.split_once(' ')?;
180+
let command = command.trim();
181+
let executable = command.split_whitespace().next().unwrap_or("");
182+
let is_codex = executable == "codex" || executable.ends_with("/codex");
183+
let is_background =
184+
command.contains(".antigravity") || command.contains("openai.chatgpt") || command.contains(".vscode");
185+
let is_switcher = command.contains("codex-switcher") || command.contains("Codex Switcher");
186+
let is_codex_app_server =
187+
command.contains("/Codex.app/Contents/Resources/codex app-server")
188+
|| command.contains("/Applications/Codex.app/Contents/Resources/codex app-server");
189+
190+
if !is_codex || is_switcher || is_codex_app_server {
191+
return None;
192+
}
193+
194+
let pid = pid_str.trim().parse::<u32>().ok()?;
195+
Some(RunningCodexProcess {
196+
pid,
197+
command: command.to_string(),
198+
is_background,
199+
})
200+
}
201+
202+
fn format_graceful_shutdown_timeout(processes: &[RunningCodexProcess]) -> String {
203+
let details = processes
204+
.iter()
205+
.map(|process| format!("pid {} ({})", process.pid, summarize_command(&process.command)))
206+
.collect::<Vec<_>>()
207+
.join(", ");
208+
format!("Timed out waiting for Codex processes to close gracefully: {details}")
209+
}
210+
211+
fn summarize_command(command: &str) -> String {
212+
const MAX_LEN: usize = 80;
213+
if command.chars().count() <= MAX_LEN {
214+
return command.to_string();
215+
}
216+
217+
let summary = command.chars().take(MAX_LEN - 3).collect::<String>();
218+
format!("{summary}...")
219+
}
220+
194221
#[cfg(windows)]
195222
fn collect_running_codex_processes_windows() -> anyhow::Result<Vec<RunningCodexProcess>> {
196223
let mut processes = Vec::new();
@@ -221,3 +248,42 @@ fn collect_running_codex_processes_windows() -> anyhow::Result<Vec<RunningCodexP
221248

222249
Ok(processes)
223250
}
251+
252+
#[cfg(test)]
253+
mod tests {
254+
use super::*;
255+
256+
#[test]
257+
fn ignores_codex_app_server_processes() {
258+
let line = "5989 /Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled";
259+
260+
let process = parse_unix_process_line(line);
261+
262+
assert!(process.is_none());
263+
}
264+
265+
#[test]
266+
fn timeout_error_lists_remaining_processes() {
267+
let processes = vec![
268+
RunningCodexProcess {
269+
pid: 100,
270+
command: String::from(
271+
"/opt/homebrew/lib/node_modules/@openai/codex/vendor/codex/codex resume 123",
272+
),
273+
is_background: false,
274+
},
275+
RunningCodexProcess {
276+
pid: 200,
277+
command: String::from("/Applications/Codex.app/Contents/Resources/codex app-server"),
278+
is_background: true,
279+
},
280+
];
281+
282+
let message = format_graceful_shutdown_timeout(&processes);
283+
284+
assert!(message.contains("100"));
285+
assert!(message.contains("resume 123"));
286+
assert!(message.contains("200"));
287+
assert!(message.contains("app-server"));
288+
}
289+
}

0 commit comments

Comments
 (0)