Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 23 additions & 60 deletions src-tauri/shared/front/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ let pendingLoginRetry: (() => Promise<void>) | null = null;
let cancelledLoginProfile: string | null = null;

function isRefreshPending(profile: string): boolean {
return state.refreshActiveProfile === profile || state.refreshQueue.includes(profile);
return state.refreshActiveProfiles.includes(profile);
}

function clearDialogError(element: HTMLParagraphElement): void {
Expand Down Expand Up @@ -261,71 +261,30 @@ async function handleSwitchProfile(profile: string): Promise<void> {
}
}

async function drainRefreshQueue(): Promise<void> {
if (state.refreshWorkerActive) {
return;
}

state.refreshWorkerActive = true;
async function performProfileRefresh(profile: string): Promise<void> {
state.refreshActiveProfiles.push(profile);
rerenderDashboard();
try {
while (state.refreshQueue.length > 0) {
const profile = state.refreshQueue.shift();
if (!profile) {
continue;
}

state.refreshActiveProfile = profile;
rerenderDashboard();
try {
await refreshProfile(profile);
showToast(t(state.locale, "refreshedProfile", { profile }));
// The backend already wrote the new quota / plan into the
// profiles index. Re-reading the snapshot picks those up
// for every card without paying for a JSONL scan.
//
// `getCurrentLiveQuota` only matters when the refreshed
// profile is also the active one — the live JSONL session
// count can be newer than the API value we just persisted
// (an in-flight `codex` session keeps appending
// `token_count` events) and `select_current_quota` picks
// the newer of the two. For non-active refreshes we skip
// it; the active card panel for that profile is rebuilt
// when the user switches to it, and the 15s ticker keeps
// the panel honest in the meantime.
try {
const snapshot = await getProfilesSnapshot();
applySnapshot(snapshot);
if (snapshot.current_card?.folder_name === profile) {
applyCurrentQuota(await getCurrentLiveQuota());
}
} catch (error) {
// Best-effort: a transient snapshot fetch failure leaves
// the cards on their pre-refresh state, which matches the
// pre-PR `refreshAllData(false)` behavior. Surface only
// to the console so a systematic failure is debuggable
// without spamming the user with a toast they can't act
// on.
console.warn("Snapshot refresh after profile refresh failed:", error);
}
} catch (error) {
showToast(refreshProfileErrorMessage(error), true);
} finally {
state.refreshActiveProfile = null;
rerenderDashboard();
await refreshProfile(profile);
showToast(t(state.locale, "refreshedProfile", { profile }));
try {
const snapshot = await getProfilesSnapshot();
applySnapshot(snapshot);
if (snapshot.current_card?.folder_name === profile) {
applyCurrentQuota(await getCurrentLiveQuota());
}
} catch (error) {
console.warn("Snapshot refresh after profile refresh failed:", error);
}
} catch (error) {
showToast(refreshProfileErrorMessage(error), true);
} finally {
state.refreshWorkerActive = false;
state.refreshActiveProfiles = state.refreshActiveProfiles.filter(p => p !== profile);
rerenderDashboard();
}
}

function handleRefreshProfile(profile: string): void {
// Mirror `handleLoginProfile`'s `isRefreshPending(profile)` guard in
// the opposite direction: when the same profile already has a login
// in flight, both flows would otherwise race on writing per-profile
// `auth.json`. Cross-profile refresh during a login is still allowed
// (different sandbox + different `auth.json`).
if (
state.loading
|| state.loginActiveProfile === profile
Expand All @@ -334,9 +293,7 @@ function handleRefreshProfile(profile: string): void {
return;
}

state.refreshQueue.push(profile);
rerenderDashboard();
void drainRefreshQueue();
void performProfileRefresh(profile);
Comment on lines 293 to +296
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Removing serial refresh queue allows concurrent refreshes that race on the shared refresh_runtime directory

The old drainRefreshQueue serialized all profile refreshes one-at-a-time via the refreshWorkerActive / refreshQueue pattern. The new performProfileRefresh fires each refresh immediately and concurrently (via void performProfileRefresh(profile)). While the guard at src-tauri/shared/front/actions.ts:288-294 prevents duplicate refreshes for the same profile, it allows concurrent refreshes for different profiles.

This introduces a race condition in the backend's refresh_via_app_server fallback path: both mac/runtime/refresh_runtime.rs:224-264 and win/runtime/refresh_runtime.rs:207-239 call prepare_refresh_runtime_home, which copies the requesting profile's auth.json into a single shared directory (refresh_runtime/, defined at src-tauri/shared/runtime/paths.rs:15). If two profiles both fall back to the app-server path concurrently, they race on writing their respective auth.json into the same directory, then each spawns a codex app-server process against the mixed state, and finally copies the result back. This can cause one profile to receive the other's auth tokens — a data integrity and security issue.

Why the fast HTTP path is unaffected

The primary try_refresh_via_chatgpt_api path uses direct HTTP calls against profile-specific auth.json files in the backup root and doesn't touch the shared runtime directory, so concurrent fast-path refreshes are safe. The race only surfaces when two or more profiles both fall through to the refresh_via_app_server fallback (e.g., non-OAuth profiles, or transient HTTP failures).

(Refers to lines 264-296)

Prompt for agents
The PR replaced the serial drainRefreshQueue worker (which processed one refresh at a time via a while loop guarded by refreshWorkerActive) with a concurrent performProfileRefresh that fires each refresh immediately. This is unsafe because the backend refresh_via_app_server fallback path on both macOS (mac/runtime/refresh_runtime.rs, function refresh_via_app_server) and Windows (win/runtime/refresh_runtime.rs, function refresh_via_app_server) shares a single refresh_runtime directory for staging auth.json and spawning the codex app-server process.

Two possible approaches:
1. Restore serialization in the front-end: keep the new refreshActiveProfiles array for UI state, but ensure only one performProfileRefresh runs at a time (e.g., a simple mutex/queue that awaits the previous refresh before starting the next).
2. Fix the backend: give each concurrent refresh its own isolated runtime directory (e.g., append the profile name or a unique suffix to the refresh_runtime path) so they don't share auth.json. This requires changes in both mac/runtime/refresh_runtime.rs and win/runtime/refresh_runtime.rs (or shared/runtime/paths.rs for the Windows path).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

function loginErrorCode(error: unknown): string | undefined {
Expand Down Expand Up @@ -1007,6 +964,12 @@ export function bootstrap(): void {
void refreshActiveQuotaSilently();
}, 5 * 60_000);

// Relative countdown timer tick: rerender the dashboard every 15 seconds
// to update the remaining relative countdown times.
window.setInterval(() => {
rerenderDashboard();
}, 15_000);

// Bulk plan refresh: forces an OAuth refresh on every OAuth profile so
// the cached id_token claims (plan tier, subscription expiry) move
// forward even for inactive profiles that the 5-min ticker never
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/shared/front/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const enMessages = {
weeklyAllowance: "Weekly allowance",
refresh: "Refresh {value}",
refreshButton: "Refresh",
resetsIn: "Resets in {value}",
resetting: "Resetting...",
loginButton: "Login",
baseButton: "Base",
planUnknownPaid: "Unknown paid plan",
Expand Down Expand Up @@ -299,6 +301,8 @@ const messages: Record<Locale, Messages> = {
weeklyAllowance: "周额度",
refresh: "刷新时间 {value}",
refreshButton: "刷新",
resetsIn: "{value} 后重置",
resetting: "重置中...",
loginButton: "登录",
baseButton: "Base",
planUnknownPaid: "未知付费",
Expand Down
50 changes: 32 additions & 18 deletions src-tauri/shared/front/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,28 @@ function formatPercent(value: number | null): string {
return value == null ? "--" : `${value}%`;
}

function formatRefresh(value: string | null): string {
return value || "--";
function formatRefresh(entry: QuotaWindow | undefined): string {
if (!entry) {
return "--";
}
if (entry.reset_at_timestamp != null) {
const diff = entry.reset_at_timestamp - Math.floor(Date.now() / 1000);
if (diff > 0) {
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
if (h > 0) {
return t(state.locale, "resetsIn", { value: `${h}h ${m}m` });
} else if (m > 0) {
const s = diff % 60;
return t(state.locale, "resetsIn", { value: `${m}m ${s}s` });
} else {
return t(state.locale, "resetsIn", { value: `${diff}s` });
}
} else {
return t(state.locale, "resetting");
}
}
return entry.refresh_at || "--";
}

function escapeHtml(value: string): string {
Expand Down Expand Up @@ -333,7 +353,7 @@ function buildMetricLineMarkup(
<section class="${metricClass}${unavailable ? " is-unavailable" : ""}">
<div class="${lineClass}">
<span class="${titleClass}">${escapeHtml(label)}</span>
<span class="${refreshClass}">${escapeHtml(formatRefresh(entry?.refresh_at ?? null))}</span>
<span class="${refreshClass}">${escapeHtml(formatRefresh(entry))}</span>
<span class="${valueClass}">${escapeHtml(formatPercent(unavailable ? null : entry?.remaining_percent ?? null))}</span>
</div>
<div class="quota-track">
Expand Down Expand Up @@ -508,10 +528,8 @@ export function renderProfiles(

elements.profilesGrid.innerHTML = dashboard.profiles
.map((profile) => {
const refreshRunning = state.refreshActiveProfile === profile.folder_name;
const refreshQueued =
!refreshRunning && state.refreshQueue.includes(profile.folder_name);
const refreshPending = refreshRunning || refreshQueued;
const refreshRunning = state.refreshActiveProfiles.includes(profile.folder_name);
const refreshPending = refreshRunning;
const loginRunning = state.loginActiveProfile === profile.folder_name;
// Any in-flight login (on this card or any other) blocks new logins
// because the OAuth port and `.switch.lock` are global resources.
Expand All @@ -526,20 +544,16 @@ export function renderProfiles(
const baseDisabled = state.loading || cardBusy;
const switchDisabled =
!profile.auth_present || state.loading || cardBusy || loginPending || profile.status === "current";
// The login button stays clickable while *this* card's login is in
// flight so the user can cancel the codex login process when they
// close the OAuth tab without finishing. It's still disabled when
// some other card holds the global login lock.

const refreshTitle = refreshRunning
? t(state.locale, "profileRefreshRunning")
: refreshDisabled
? t(state.locale, "profileRefreshDisabled")
: t(state.locale, "profileRefreshReady");

const loginDisabled =
state.loading || refreshPending || (loginPending && !loginRunning);
const unavailable = isProfileUnavailable(profile);
const refreshTitle = refreshRunning
? t(state.locale, "profileRefreshRunning")
: refreshQueued
? t(state.locale, "profileRefreshQueued")
: refreshDisabled
? t(state.locale, "profileRefreshDisabled")
: t(state.locale, "profileRefreshReady");

const planTooltip = planFreshnessTitle(profile.plan_name, profile.last_plan_check_ms);
const planClasses = [
Expand Down
4 changes: 1 addition & 3 deletions src-tauri/shared/front/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import type { ThemeId } from "@front-shared/theme";
export const state = {
page: 1,
loading: false,
refreshQueue: [] as string[],
refreshActiveProfile: null as string | null,
refreshWorkerActive: false,
refreshActiveProfiles: [] as string[],
loginActiveProfile: null as string | null,
currentProfile: null as string | null,
route: "dashboard" as ShellRoute,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/shared/front/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ function quota(
five_hour: {
remaining_percent: fiveHourPercent,
refresh_at: fiveHourRefresh,
reset_at_timestamp: null,
},
weekly: {
remaining_percent: weeklyPercent,
refresh_at: weeklyRefresh,
reset_at_timestamp: null,
},
};
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/shared/front/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface QuotaWindow {
remaining_percent: number | null;
refresh_at: string | null;
reset_at_timestamp: number | null;
}

export interface QuotaSummary {
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/shared/runtime/chatgpt_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub fn looks_like_relogin_required(error_code: &str, message: &str) -> bool {
}
/// Refresh the access token a little before its actual expiry so a 401
/// in-flight does not bubble up to the caller.
const EXPIRY_SKEW_SECONDS: i64 = 60;
const EXPIRY_SKEW_SECONDS: i64 = 300;

/// Outcome of a single ChatGPT-API refresh round-trip.
///
Expand Down Expand Up @@ -626,6 +626,7 @@ fn quota_window_from_rate_limit(window: &RateLimitWindow) -> QuotaWindow {
QuotaWindow {
remaining_percent,
refresh_at,
reset_at_timestamp: window.reset_at,
}
}

Expand Down
25 changes: 12 additions & 13 deletions src-tauri/shared/runtime/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,22 +386,21 @@ fn quota_window_from_app_server(window: &Value) -> QuotaWindow {
});
let remaining_percent =
used_percent.map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8);
let refresh_at = window
.get("resetsAt")
.and_then(Value::as_i64)
.and_then(|seconds| {
Utc.timestamp_opt(seconds, 0)
.single()
.map(|datetime| {
datetime
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
});
let reset_at_timestamp = window.get("resetsAt").and_then(Value::as_i64);
let refresh_at = reset_at_timestamp.and_then(|seconds| {
Utc.timestamp_opt(seconds, 0)
.single()
.map(|datetime| {
datetime
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
});
QuotaWindow {
remaining_percent,
refresh_at,
reset_at_timestamp,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/shared/runtime/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ mod tests {
five_hour: QuotaWindow {
remaining_percent: Some(99),
refresh_at: Some("2026-04-21 13:37".to_string()),
..QuotaWindow::default()
},
..QuotaSummary::default()
}
Expand Down Expand Up @@ -568,6 +569,7 @@ mod tests {
weekly: QuotaWindow {
remaining_percent: Some(82),
refresh_at: Some("2026-05-15 12:00".to_string()),
..QuotaWindow::default()
},
},
..ProfileMetadata::default()
Expand Down
1 change: 1 addition & 0 deletions src-tauri/shared/runtime/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct QuotaWindow {
pub remaining_percent: Option<u8>,
pub refresh_at: Option<String>,
pub reset_at_timestamp: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/shared/runtime/profiles_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ pub fn load_profiles_index(codex_home: Option<&Path>) -> AppResult<ProfilesIndex
if let Some(cached) = try_load_cached_index(&codex_home) {
return Ok(cached);
}

static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
let _guard = LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner());

// Double check cache after acquiring the lock to avoid redundant disk reads/writes
if let Some(cached) = try_load_cached_index(&codex_home) {
return Ok(cached);
}

let backup_root = get_backup_root(Some(&codex_home));
let (mut index, mut changed) = match load_profiles_index_file(&codex_home) {
Some(index) => (index, false),
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/shared/runtime/quota_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,12 @@ mod tests {
five_hour: QuotaWindow {
remaining_percent: Some(80),
refresh_at: Some("2026-05-10 10:00".to_string()),
..QuotaWindow::default()
},
weekly: QuotaWindow {
remaining_percent: Some(50),
refresh_at: None,
..QuotaWindow::default()
},
}
}
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/shared/runtime/session_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ fn normalize_quota_window(window: QuotaWindow) -> QuotaWindow {
QuotaWindow {
remaining_percent: window.remaining_percent.map(|value| value.min(100)),
refresh_at: window.refresh_at,
reset_at_timestamp: window.reset_at_timestamp,
}
}

Expand Down Expand Up @@ -113,10 +114,12 @@ fn quota_window_from_rate_limit(window: Option<SessionRateLimitWindow>) -> Quota
.used_percent
.map(|used_percent| (100.0 - used_percent).round().clamp(0.0, 100.0) as u8);
let refresh_at = window.resets_at.and_then(format_reset_time);
let reset_at_timestamp = window.resets_at;

QuotaWindow {
remaining_percent,
refresh_at,
reset_at_timestamp,
}
}

Expand Down Expand Up @@ -429,6 +432,7 @@ mod tests {
five_hour: QuotaWindow {
remaining_percent: Some(99),
refresh_at: None,
..QuotaWindow::default()
},
weekly: QuotaWindow::default(),
},
Expand Down
Loading