mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(tui): reduce startup and new-session latency (#17039)
## TL;DR
- Fetches account/rateLimits/read asynchronously so the TUI can continue
starting without waiting for the rate-limit response.
- Fixes the /status card so it no longer leaves a stale “refreshing
cached limits...” notice in terminal history.
## Problem
The TUI bootstrap path fetched account rate limits synchronously
(`account/rateLimits/read`) before the event loop started for
ChatGPT/OpenAI-authenticated startups. This added ~670 ms of blocking
latency in the measured hot-start case, even though rate-limit data is
not needed to render the initial UI or accept user input. The delay was
especially noticeable on hot starts where every other RPC
(`account/read`, `model/list`, `thread/start`) completed in under 70 ms
total.
Moving that fetch to the background also exposed a `/status` UI bug: the
status card is flattened into terminal scrollback when it is inserted. A
transient "refreshing limits in background..." line could not be cleared
later, because the async completion updated the retained `HistoryCell`,
not the already-written terminal history.
## Mental model
Before this change, `AppServerSession::bootstrap()` performed three
sequential RPCs: `account/read` → `model/list` →
`account/rateLimits/read`. The result of the third call was baked into
`AppServerBootstrap` and applied to the chat widget before the event
loop began.
After this change, `bootstrap()` only performs two RPCs (`account/read`
+ `model/list`), and rate-limit fetching is kicked off as an async
background task immediately after the first frame is scheduled. A new
enum, `RateLimitRefreshOrigin`, tags each fetch so the event handler
knows whether the result came from the startup prefetch or from a
user-initiated `/status` command; they have different completion
side-effects.
The `get_login_status()` helper (used outside the main app flow) was
also decoupled: it previously called the full `bootstrap()` just to
check auth mode, wasting model-list and rate-limit work. It now calls
the narrower `read_account()` directly.
For `/status`, this PR keeps the background refresh request but stops
printing transient refresh notices into status history when cached
limits are already available. If a refresh updates the cache, the next
`/status` command will render the new values.
## Non-goals
- This change does not alter the rate-limit data itself.
- This change does not introduce caching, retries, or staleness
management for rate limits.
- This change does not affect the `model/list` or `thread/start` RPCs;
they remain on the critical startup path.
## Tradeoffs
- **Stale-on-first-render**: The status bar will briefly show no
rate-limit info until the background fetch completes; observed
background fetches landed roughly in the 400-900 ms range after the UI
appeared. This is acceptable because the user cannot meaningfully act on
rate-limit data in the first fraction of a second.
- **Error silence on startup prefetch**: If the startup prefetch fails,
the error is logged but the UI is not notified (unlike `/status` refresh
failures, which go through the status-command completion path). This
avoids surfacing transient network errors as a startup blocker.
- **Static `/status` history**: `/status` output is terminal history,
not a live widget. The card now avoids progress-style language that
would appear stuck in scrollback; users can run `/status` again to see
newly cached values.
- **`account_auth_mode` field removed from `AppServerBootstrap`**: The
only consumer was `get_login_status()`, which no longer goes through
`bootstrap()`. The field was dead weight.
## Architecture
### New types
- `RateLimitRefreshOrigin` (in `app_event.rs`): A `Copy` enum
distinguishing `StartupPrefetch` from `StatusCommand { request_id }`.
Carried through `RefreshRateLimits` and `RateLimitsLoaded` events so the
handler applies the right completion behavior.
### Modified types
- `AppServerBootstrap`: Lost `account_auth_mode` and
`rate_limit_snapshots`; gained `requires_openai_auth: bool` (passed
through from the account response so the caller can decide whether to
fire the prefetch).
### Control flow
1. `bootstrap()` returns with `requires_openai_auth` and
`has_chatgpt_account`.
2. After scheduling the first frame, `App::run_inner` fires
`refresh_rate_limits(StartupPrefetch)` if both flags are true.
3. When `RateLimitsLoaded { StartupPrefetch, Ok(..) }` arrives,
snapshots are applied and a frame is scheduled to repaint the status
bar.
4. When `RateLimitsLoaded { StartupPrefetch, Err(..) }` arrives, the
error is logged and no UI update occurs.
5. `/status`-initiated refreshes continue to use `StatusCommand {
request_id }` and call `finish_status_rate_limit_refresh` on completion
(success or failure).
6. `/status` history cells with cached rate-limit rows no longer render
an additional "refreshing limits" notice; the async refresh updates the
cache for future status output.
### Extracted method
- `AppServerSession::read_account()`: Factored out of `bootstrap()` so
that `get_login_status()` can call it independently without triggering
model-list or rate-limit work.
## Observability
- The existing `tracing::warn!` for rate-limit fetch failures is
preserved for the startup path.
- No new metrics or spans are introduced. The startup-time improvement
is observable via the existing `ready` timestamp in TUI startup logs.
## Tests
- Existing tests in `status_command_tests.rs` are updated to match on
`RateLimitRefreshOrigin::StatusCommand { request_id }` instead of a bare
`request_id`.
- Focused `/status` tests now assert that status history avoids
transient refresh text, continues to request an async refresh, and uses
refreshed cached limits in future status output.
- No new tests are added for the startup prefetch path because it is a
fire-and-forget spawn with no observable side-effect other than the
widget state update, which is already covered by the
snapshot-application tests.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
80ebc80be5
commit
359e17a852
@@ -92,17 +92,25 @@ use color_eyre::eyre::WrapErr;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Data collected during the TUI bootstrap phase that the main event loop
|
||||
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
|
||||
///
|
||||
/// Rate-limit snapshots are intentionally **not** included here; they are
|
||||
/// fetched asynchronously after bootstrap returns so that the TUI can render
|
||||
/// its first frame without waiting for the rate-limit round-trip.
|
||||
pub(crate) struct AppServerBootstrap {
|
||||
pub(crate) account_auth_mode: Option<AuthMode>,
|
||||
pub(crate) account_email: Option<String>,
|
||||
pub(crate) auth_mode: Option<TelemetryAuthMode>,
|
||||
pub(crate) status_account_display: Option<StatusAccountDisplay>,
|
||||
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
|
||||
/// Whether the configured model provider needs OpenAI-style auth. Combined
|
||||
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
|
||||
/// should be fired.
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
pub(crate) default_model: String,
|
||||
pub(crate) feedback_audience: FeedbackAudience,
|
||||
pub(crate) has_chatgpt_account: bool,
|
||||
pub(crate) available_models: Vec<ModelPreset>,
|
||||
pub(crate) rate_limit_snapshots: Vec<RateLimitSnapshot>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppServerSession {
|
||||
@@ -173,17 +181,7 @@ impl AppServerSession {
|
||||
}
|
||||
|
||||
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
||||
let account_request_id = self.next_request_id();
|
||||
let account: GetAccountResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::GetAccount {
|
||||
request_id: account_request_id,
|
||||
params: GetAccountParams {
|
||||
refresh_token: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("account/read failed during TUI bootstrap")?;
|
||||
let account = self.read_account().await?;
|
||||
let model_request_id = self.next_request_id();
|
||||
let models: ModelListResponse = self
|
||||
.client
|
||||
@@ -215,7 +213,6 @@ impl AppServerSession {
|
||||
.wrap_err("model/list returned no models for TUI bootstrap")?;
|
||||
|
||||
let (
|
||||
account_auth_mode,
|
||||
account_email,
|
||||
auth_mode,
|
||||
status_account_display,
|
||||
@@ -224,7 +221,6 @@ impl AppServerSession {
|
||||
has_chatgpt_account,
|
||||
) = match account.account {
|
||||
Some(Account::ApiKey {}) => (
|
||||
Some(AuthMode::ApiKey),
|
||||
None,
|
||||
Some(TelemetryAuthMode::ApiKey),
|
||||
Some(StatusAccountDisplay::ApiKey),
|
||||
@@ -239,7 +235,6 @@ impl AppServerSession {
|
||||
FeedbackAudience::External
|
||||
};
|
||||
(
|
||||
Some(AuthMode::Chatgpt),
|
||||
Some(email.clone()),
|
||||
Some(TelemetryAuthMode::Chatgpt),
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
@@ -251,50 +246,38 @@ impl AppServerSession {
|
||||
true,
|
||||
)
|
||||
}
|
||||
None => (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
FeedbackAudience::External,
|
||||
false,
|
||||
),
|
||||
None => (None, None, None, None, FeedbackAudience::External, false),
|
||||
};
|
||||
let rate_limit_snapshots = if account.requires_openai_auth && has_chatgpt_account {
|
||||
let rate_limit_request_id = self.next_request_id();
|
||||
match self
|
||||
.client
|
||||
.request_typed(ClientRequest::GetAccountRateLimits {
|
||||
request_id: rate_limit_request_id,
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits),
|
||||
Err(err) => {
|
||||
tracing::warn!("account/rateLimits/read failed during TUI bootstrap: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(AppServerBootstrap {
|
||||
account_auth_mode,
|
||||
account_email,
|
||||
auth_mode,
|
||||
status_account_display,
|
||||
plan_type,
|
||||
requires_openai_auth: account.requires_openai_auth,
|
||||
default_model,
|
||||
feedback_audience,
|
||||
has_chatgpt_account,
|
||||
available_models,
|
||||
rate_limit_snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches the current account info without refreshing the auth token.
|
||||
///
|
||||
/// Used by both `bootstrap` (to populate the initial UI) and `get_login_status`
|
||||
/// (to check auth mode without the overhead of a full bootstrap).
|
||||
pub(crate) async fn read_account(&mut self) -> Result<GetAccountResponse> {
|
||||
let account_request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::GetAccount {
|
||||
request_id: account_request_id,
|
||||
params: GetAccountParams {
|
||||
refresh_token: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("account/read failed during TUI bootstrap")
|
||||
}
|
||||
|
||||
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
||||
self.client.next_event().await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user