Skip to content

feat(usage): real-time stats refresh + fix codex sync panic on non-ASCII model names#3027

Merged
farion1231 merged 1 commit into
farion1231:mainfrom
in30mn1a:fix/usage-stats-realtime-and-codex-panic
May 29, 2026
Merged

feat(usage): real-time stats refresh + fix codex sync panic on non-ASCII model names#3027
farion1231 merged 1 commit into
farion1231:mainfrom
in30mn1a:fix/usage-stats-realtime-and-codex-panic

Conversation

@in30mn1a

Copy link
Copy Markdown
Contributor

Summary

This PR addresses a long-standing issue where the usage statistics dashboard only refreshed after restarting cc-switch. Diagnosing this surfaced two root causes — both fixed here.

Problem 1: Codex session sync panics on non-ASCII model names

normalize_codex_model in services/session_usage_codex.rs slices the last 11 bytes of the model name to detect an ISO date suffix:

let suffix = &name[name.len() - 11..];

For a model name containing multi-byte UTF-8 characters (real-world example from my config: 【官】glm-5.1), name.len() - 11 lands inside a multi-byte char, panicking the tokio worker:

thread 'tokio-rt-worker' panicked at services/session_usage_codex.rs:79:27:
start byte index 5 is not a char boundary; it is inside '官' (bytes 3..6) of `【官】glm-5.1`

Once the background sync task panics, no Codex session logs are imported until the app is restarted (where Database::initrollup_and_prune happens to flush pending data). This is the actual reason stats appear to "only refresh on restart" for users who don't route through the cc-switch proxy.

Fix: Guard the slice with is_char_boundary + is_ascii (a valid date suffix is always ASCII, so non-ASCII names can safely bypass the slicing).

Problem 2: Dashboard doesn't reflect new logs in real time

Even after fixing Problem 1, the UsageDashboard only polls every 30s and disables refetchIntervalInBackground, so freshly-imported data is invisible until the next poll or window refocus.

Fix: Push-based refresh.

  • New usage_events module emits a usage-log-recorded event whenever proxy_request_logs actually gains a new row. Coverage:
    • Proxy log_request (streaming + non-streaming, success + error)
    • Claude / Codex / Gemini session sync (only when a row is truly inserted, so INSERT OR IGNORE skips don't trigger empty refreshes)
    • Startup rollup_and_prune
  • 200ms debounce inside notify_log_recorded collapses bursts. A Codex sync importing 3000+ entries produces ~2 emits, not 3000.
  • Global OnceLock<AppHandle> lets call sites that don't already hold an AppHandle (e.g. UsageLogger) notify without signature churn.
  • Frontend useUsageEventBridge listens for the event and invalidates usageKeys.all. Mounted only on UsageDashboard so the listener is unsubscribed automatically when navigating away.

Verification

  • cargo check passes (existing 25 dead-code warnings in commands/misc.rs are pre-existing and unrelated)
  • tsc --noEmit passes
  • ✅ Manually verified end-to-end: with my actual config containing the panic-triggering model name, Codex sync imported 3145 entries successfully, produced 2 debounced emits (both emit usage-log-recorded 成功 in logs), and the dashboard updated within ~200ms.

Files

Type File
🐛 Fix src-tauri/src/services/session_usage_codex.rs (the panic)
✨ New src-tauri/src/usage_events.rs (event module)
✨ New src/hooks/useUsageEventBridge.ts (frontend listener)
🔧 Edit src-tauri/src/lib.rs (init in setup)
🔧 Edit src-tauri/src/proxy/usage/logger.rs (notify on log_request)
🔧 Edit src-tauri/src/services/session_usage{,_codex,_gemini}.rs (notify on insert)
🔧 Edit src-tauri/src/database/dao/usage_rollup.rs (notify on prune)
🔧 Edit src/components/usage/UsageDashboard.tsx (mount the bridge hook)

Behaviour notes

  • The session-sync notify paths only fire when a row is actually inserted, so dedup-skipped writes don't cause unnecessary frontend refreshes.
  • Gemini's INSERT … ON CONFLICT … DO UPDATE path reuses the existing conn.changes() > 0 check.
  • AppHandle is injected after the log plugin is registered, so the init log line is captured in cc-switch.log.

…CII model names

The usage dashboard previously only refreshed on app restart for users
who don't route through the cc-switch proxy. Two issues were involved:

1. The session-sync background task panicked when a Codex model name
   contained non-ASCII characters (e.g. `【官】glm-5.1`), because
   `normalize_codex_model` sliced `&name[name.len() - 11..]` without
   verifying char boundaries. Once the task panicked, no session logs
   were imported until the app was restarted (where startup-time
   `rollup_and_prune` happened to flush pending data).

2. Even with sync working, the dashboard only polled every 30s and
   skipped polling when the window was unfocused, so freshly-imported
   data was invisible until the next poll or window refocus.

Fixes
-----

* `normalize_codex_model`: guard the 11-byte ISO-date suffix slice with
  `is_char_boundary` + `is_ascii` checks. ASCII-only suffix means the
  date-stripping logic is correct, and non-ASCII names (which can never
  be valid date suffixes anyway) now bypass the slice safely.

* New `usage_events` module that emits `usage-log-recorded` to the
  frontend whenever `proxy_request_logs` actually gains a new row.
  Sources covered: proxy `log_request`, Claude/Codex/Gemini session
  sync, and startup `rollup_and_prune`. Notifications use a global
  `OnceLock<AppHandle>` so call sites that don't already hold an
  `AppHandle` (e.g. `UsageLogger`) can notify without signature churn.

* 200ms debounce in `notify_log_recorded` collapses bursts (a single
  Codex sync importing 3000+ entries triggers ~2 emits, not 3000) so
  the frontend's `invalidateQueries` is never spammed.

* Frontend `useUsageEventBridge` listens for the event and invalidates
  `usageKeys.all`. Hook is mounted only on `UsageDashboard`, so the
  listener is unsubscribed automatically when the user navigates away.

Verification
------------

* `cargo check` passes (existing 25 dead-code warnings in
  `commands/misc.rs` are pre-existing and unrelated).
* `tsc --noEmit` passes.
* Manually verified end-to-end: a Codex sync run that imported 3145
  entries produced 2 debounced emits, both logged as `emit
  usage-log-recorded 成功`, and the dashboard updated within ~200ms.

Behaviour notes
---------------

* `INSERT OR IGNORE` paths (Claude/Codex session sync) only notify when
  the row is actually inserted, so dedup-skipped writes don't trigger
  empty refreshes.
* Gemini's `INSERT … ON CONFLICT … DO UPDATE` path reuses the existing
  `conn.changes() > 0` check and only notifies when token counts truly
  changed.
* `rollup_and_prune` notifies once per pruning cycle (at most once per
  app start) so the dashboard reflects the new aggregate state.
@farion1231

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for your contribution!

@farion1231
farion1231 merged commit bc1467d into farion1231:main May 29, 2026
2 checks passed
royccai pushed a commit to royccai/cc-switch-mod that referenced this pull request May 31, 2026
…CII model names (farion1231#3027)

The usage dashboard previously only refreshed on app restart for users
who don't route through the cc-switch proxy. Two issues were involved:

1. The session-sync background task panicked when a Codex model name
   contained non-ASCII characters (e.g. `【官】glm-5.1`), because
   `normalize_codex_model` sliced `&name[name.len() - 11..]` without
   verifying char boundaries. Once the task panicked, no session logs
   were imported until the app was restarted (where startup-time
   `rollup_and_prune` happened to flush pending data).

2. Even with sync working, the dashboard only polled every 30s and
   skipped polling when the window was unfocused, so freshly-imported
   data was invisible until the next poll or window refocus.

Fixes
-----

* `normalize_codex_model`: guard the 11-byte ISO-date suffix slice with
  `is_char_boundary` + `is_ascii` checks. ASCII-only suffix means the
  date-stripping logic is correct, and non-ASCII names (which can never
  be valid date suffixes anyway) now bypass the slice safely.

* New `usage_events` module that emits `usage-log-recorded` to the
  frontend whenever `proxy_request_logs` actually gains a new row.
  Sources covered: proxy `log_request`, Claude/Codex/Gemini session
  sync, and startup `rollup_and_prune`. Notifications use a global
  `OnceLock<AppHandle>` so call sites that don't already hold an
  `AppHandle` (e.g. `UsageLogger`) can notify without signature churn.

* 200ms debounce in `notify_log_recorded` collapses bursts (a single
  Codex sync importing 3000+ entries triggers ~2 emits, not 3000) so
  the frontend's `invalidateQueries` is never spammed.

* Frontend `useUsageEventBridge` listens for the event and invalidates
  `usageKeys.all`. Hook is mounted only on `UsageDashboard`, so the
  listener is unsubscribed automatically when the user navigates away.

Verification
------------

* `cargo check` passes (existing 25 dead-code warnings in
  `commands/misc.rs` are pre-existing and unrelated).
* `tsc --noEmit` passes.
* Manually verified end-to-end: a Codex sync run that imported 3145
  entries produced 2 debounced emits, both logged as `emit
  usage-log-recorded 成功`, and the dashboard updated within ~200ms.

Behaviour notes
---------------

* `INSERT OR IGNORE` paths (Claude/Codex session sync) only notify when
  the row is actually inserted, so dedup-skipped writes don't trigger
  empty refreshes.
* Gemini's `INSERT … ON CONFLICT … DO UPDATE` path reuses the existing
  `conn.changes() > 0` check and only notifies when token counts truly
  changed.
* `rollup_and_prune` notifies once per pruning cycle (at most once per
  app start) so the dashboard reflects the new aggregate state.

Co-authored-by: in30mn1a <[email protected]>
delta-lo pushed a commit to delta-lo/cc-switch-mod that referenced this pull request May 31, 2026
…CII model names (farion1231#3027)

The usage dashboard previously only refreshed on app restart for users
who don't route through the cc-switch proxy. Two issues were involved:

1. The session-sync background task panicked when a Codex model name
   contained non-ASCII characters (e.g. `【官】glm-5.1`), because
   `normalize_codex_model` sliced `&name[name.len() - 11..]` without
   verifying char boundaries. Once the task panicked, no session logs
   were imported until the app was restarted (where startup-time
   `rollup_and_prune` happened to flush pending data).

2. Even with sync working, the dashboard only polled every 30s and
   skipped polling when the window was unfocused, so freshly-imported
   data was invisible until the next poll or window refocus.

Fixes
-----

* `normalize_codex_model`: guard the 11-byte ISO-date suffix slice with
  `is_char_boundary` + `is_ascii` checks. ASCII-only suffix means the
  date-stripping logic is correct, and non-ASCII names (which can never
  be valid date suffixes anyway) now bypass the slice safely.

* New `usage_events` module that emits `usage-log-recorded` to the
  frontend whenever `proxy_request_logs` actually gains a new row.
  Sources covered: proxy `log_request`, Claude/Codex/Gemini session
  sync, and startup `rollup_and_prune`. Notifications use a global
  `OnceLock<AppHandle>` so call sites that don't already hold an
  `AppHandle` (e.g. `UsageLogger`) can notify without signature churn.

* 200ms debounce in `notify_log_recorded` collapses bursts (a single
  Codex sync importing 3000+ entries triggers ~2 emits, not 3000) so
  the frontend's `invalidateQueries` is never spammed.

* Frontend `useUsageEventBridge` listens for the event and invalidates
  `usageKeys.all`. Hook is mounted only on `UsageDashboard`, so the
  listener is unsubscribed automatically when the user navigates away.

Verification
------------

* `cargo check` passes (existing 25 dead-code warnings in
  `commands/misc.rs` are pre-existing and unrelated).
* `tsc --noEmit` passes.
* Manually verified end-to-end: a Codex sync run that imported 3145
  entries produced 2 debounced emits, both logged as `emit
  usage-log-recorded 成功`, and the dashboard updated within ~200ms.

Behaviour notes
---------------

* `INSERT OR IGNORE` paths (Claude/Codex session sync) only notify when
  the row is actually inserted, so dedup-skipped writes don't trigger
  empty refreshes.
* Gemini's `INSERT … ON CONFLICT … DO UPDATE` path reuses the existing
  `conn.changes() > 0` check and only notifies when token counts truly
  changed.
* `rollup_and_prune` notifies once per pruning cycle (at most once per
  app start) so the dashboard reflects the new aggregate state.

Co-authored-by: in30mn1a <[email protected]>
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.

2 participants