fix: phase 2 — UI-freeze + GDI-leak + panic-on-GDI-exhaustion fixes

- P0: pull blocking HTTPS out from under the global mutex. AppState's
  http and registry now live behind Arc<Client> and Arc<Mutex<Registry>>;
  do_poll, attempt_refresh, and version_action's Apply branch clone
  these out, drop lock_state, then do their I/O. Apply now spawns a
  worker thread that posts WM_APP_UPDATE_APPLIED back to the message-
  only window when the cmd handoff is launched, so the UI no longer
  freezes for the duration of the download.
- P1: bubble.rs paint_text_layer saves and restores the DC's previous
  HFONT before DeleteObject. The old code's DeleteObject on a still-
  selected HFONT silently failed and leaked one handle per paint frame
  (up to ~12/s under the ≥95% pulse animation).
- P1: replace 5x CreatePopupMenu().unwrap() with let-else early returns
  that destroy any half-built menus and log. GDI exhaustion no longer
  panics the UI thread.
- P1: at-most-one-in-flight gate (static AtomicBool) on the poll thread
  so rapid Refresh clicks don't stack concurrent HTTPS calls.
- P1: token-expired balloon now picks the title/body for the provider
  that actually failed, instead of always falling back to Claude when
  show_claude_code is on.
- P1: panel place_near honors SM_XVIRTUALSCREEN / SM_YVIRTUALSCREEN so
  multi-monitor setups with a secondary display left of the primary
  no longer mis-clamp the panel position.
- P1: COUNTDOWN_TEMPLATE bumped from "999d" to "999시간" — Korean has
  the widest suffix among shipped locales and was overflowing the
  countdown column.

Bumps version to 0.1.4.
This commit is contained in:
2026-05-16 13:51:16 +07:00
parent 0f3acd40d4
commit 1ef1bfa7b2
5 changed files with 161 additions and 70 deletions
+130 -56
View File
@@ -6,7 +6,8 @@
// message-only window owned by this module.
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::PCWSTR;
@@ -33,6 +34,11 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
// Win32 message IDs owned by this module.
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
// Posted from the update worker thread when the swap-and-restart cmd
// handoff has been launched successfully. The UI thread responds by
// calling PostQuitMessage(0) to release the file lock on the running
// .exe so cmd.exe can overwrite it.
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
const TIMER_POLL: usize = 1;
@@ -106,8 +112,13 @@ struct AppState {
i18n: I18n,
is_dark: bool,
install_channel: InstallChannel,
http: net::Client,
registry: Registry,
// http and registry live behind Arc so worker threads can hold them
// without keeping the global state mutex locked across blocking I/O.
// The registry's own mutex is only contended by other workers
// (the in-flight gate ensures at most one poll runs at a time), so
// the UI thread never waits on it.
http: Arc<net::Client>,
registry: Arc<Mutex<Registry>>,
snapshots: HashMap<ProviderId, ProviderUiState>,
last_poll_ok: bool,
update_status: UpdateStatus,
@@ -182,8 +193,8 @@ pub fn run() {
i18n,
is_dark,
install_channel,
http,
registry: Registry::with_defaults(),
http: Arc::new(http),
registry: Arc::new(Mutex::new(Registry::with_defaults())),
snapshots: HashMap::new(),
last_poll_ok: false,
update_status: UpdateStatus::Idle,
@@ -293,6 +304,10 @@ unsafe extern "system" fn msg_wnd_proc(
propagate_to_ui();
LRESULT(0)
}
WM_APP_UPDATE_APPLIED => {
PostQuitMessage(0);
LRESULT(0)
}
WM_APP_TRAY => {
let action = tray::callback::handle(lparam);
handle_tray_action(action);
@@ -394,13 +409,29 @@ fn on_timer(hwnd: HWND, id: usize) {
// ---------- Poll thread ----------
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
/// would otherwise stack concurrent HTTPS calls onto the same registry,
/// which both wastes bandwidth and (before the registry mutex landed)
/// could let two poll cycles interleave their writes to the snapshot map.
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
fn spawn_poll_thread() {
if POLL_IN_FLIGHT
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let msg_hwnd = match lock_state().as_ref() {
Some(s) => s.msg_hwnd,
None => return,
None => {
POLL_IN_FLIGHT.store(false, Ordering::Release);
return;
}
};
std::thread::spawn(move || {
do_poll();
POLL_IN_FLIGHT.store(false, Ordering::Release);
unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
@@ -413,17 +444,23 @@ fn spawn_poll_thread() {
}
fn do_poll() {
let results = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
// Snapshot the inputs we need, then DROP the global lock before any
// HTTPS call. Holding `lock_state()` through `poll_enabled` would block
// the UI thread on every paint/menu for the duration of the request.
let (http, registry, settings) = {
let s = lock_state();
let Some(s) = s.as_ref() else {
return;
};
let settings = s.settings.clone();
s.registry.poll_enabled(&s.http, &settings)
(s.http.clone(), s.registry.clone(), s.settings.clone())
};
let results = {
let mut reg = registry.lock().expect("registry mutex poisoned");
reg.poll_enabled(&http, &settings)
};
let auth_failures = apply_results(results);
if !auth_failures.is_empty() {
attempt_refresh(auth_failures);
attempt_refresh(auth_failures, &registry);
}
}
@@ -467,21 +504,24 @@ fn apply_results(
auth_failures
}
fn attempt_refresh(failures: Vec<ProviderId>) {
fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
let mut needs_balloon = false;
// Pick the first provider whose refresh did not succeed and balloon
// for it specifically. If both providers fail in the same cycle the
// second will resurface on the next poll once the first is re-auth'd.
let mut balloon_for: Option<ProviderId> = None;
for id in failures {
let outcome = match lock_state().as_ref() {
Some(s) => s.registry.try_refresh(id, &orchestrator),
None => return,
let outcome = {
let reg = registry.lock().expect("registry mutex poisoned");
reg.try_refresh(id, &orchestrator)
};
log::info!("refresh for {id:?}: {outcome:?}");
if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
needs_balloon = true;
balloon_for.get_or_insert(id);
}
}
if needs_balloon {
show_token_expired_balloon();
if let Some(provider) = balloon_for {
show_token_expired_balloon(provider);
}
}
@@ -712,7 +752,7 @@ fn handle_tray_action(action: TrayAction) {
}
}
fn show_token_expired_balloon() {
fn show_token_expired_balloon(failed: ProviderId) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
@@ -725,20 +765,17 @@ fn show_token_expired_balloon() {
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let (kind, title, body) = if s.settings.show_claude_code {
(
ProviderId::Claude,
let (title, body) = match failed {
ProviderId::Claude => (
strings.token_expired_title.clone(),
strings.token_expired_body.clone(),
)
} else {
(
ProviderId::ChatGpt,
),
ProviderId::ChatGpt => (
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
)
),
};
(s.msg_hwnd, kind, title, body)
(s.msg_hwnd, failed, title, body)
};
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
@@ -787,7 +824,11 @@ fn show_context_menu(owner_hwnd: HWND) {
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
let freq = CreatePopupMenu().unwrap();
let Ok(freq) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(freq) failed");
let _ = DestroyMenu(menu);
return;
};
for (id, interval, label) in [
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
@@ -803,7 +844,11 @@ fn show_context_menu(owner_hwnd: HWND) {
}
append_submenu(menu, freq, &snap.strings.update_frequency);
let models = CreatePopupMenu().unwrap();
let Ok(models) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
models,
IDM_MODEL_CLAUDE,
@@ -818,7 +863,11 @@ fn show_context_menu(owner_hwnd: HWND) {
);
append_submenu(menu, models, &snap.strings.models);
let settings_menu = CreatePopupMenu().unwrap();
let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
settings_menu,
IDM_START_WITH_WINDOWS,
@@ -832,7 +881,12 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0),
);
let lang = CreatePopupMenu().unwrap();
let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item(
lang,
IDM_LANG_SYSTEM,
@@ -867,7 +921,12 @@ fn show_context_menu(owner_hwnd: HWND) {
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
let auto_update = CreatePopupMenu().unwrap();
let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
(
@@ -1086,31 +1145,46 @@ fn version_action() {
};
match act {
Act::Apply(release, channel) => {
if let Some(s) = lock_state().as_mut() {
// Set the Applying status synchronously so the menu reflects
// it immediately, then move the (potentially several-second)
// download to a worker thread. Holding the UI thread here
// would freeze paints and menus until the download finished.
let (http, msg_hwnd) = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
s.update_status = UpdateStatus::Applying;
}
let result: Result<(), Box<dyn std::error::Error>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
match net::Client::new(HTTP_USER_AGENT) {
Ok(c) => update::install::begin(&c, &release).map_err(|e| e.into()),
Err(e) => Err(e.into()),
}
}
(s.http.clone(), s.msg_hwnd)
};
match result {
Ok(()) => unsafe { PostQuitMessage(0) },
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
std::thread::spawn(move || {
let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
update::install::begin(&http, &release).map_err(|e| e.into())
}
};
match result {
Ok(()) => unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
WM_APP_UPDATE_APPLIED,
WPARAM(0),
LPARAM(0),
);
},
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
}
}
}
}
});
}
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
}
+12 -3
View File
@@ -888,7 +888,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
const LABEL_PAD_LOGICAL: i32 = 6;
const COUNTDOWN_TEMPLATE: &str = "999d";
// Sized for the widest countdown across all shipped locales. Korean
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
// out of the column. Update this when adding a locale with a longer
// suffix.
const COUNTDOWN_TEMPLATE: &str = "999시간";
// Percent now lives in its own column between the bar and the countdown so
// the two numeric readouts ("44%" and "3h") sit next to each other for
// quick scanning, and the percent never has to fight the bar's fill colour
@@ -1296,8 +1301,10 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
SetBkMode(hdc, TRANSPARENT);
// Row labels in the left column.
SelectObject(hdc, label_font);
// Save the DC's original font so we can restore it before deleting
// ours. DeleteObject silently fails on a still-selected HFONT,
// which would leak the handle on every paint frame.
let prev_font = SelectObject(hdc, label_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_label(hdc, layout, layout.row1_y, "5h");
draw_label(hdc, layout, layout.row2_y, "7d");
@@ -1314,6 +1321,8 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text);
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text);
// Restore the original font, then it is safe to delete ours.
SelectObject(hdc, prev_font);
let _ = DeleteObject(main_font);
let _ = DeleteObject(bold_font);
let _ = DeleteObject(label_font);
+17 -9
View File
@@ -504,19 +504,27 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
// Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left;
let mut y = anchor.bottom + 8;
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
if y + panel_h > virtual_screen_h {
// The virtual screen spans all monitors. Its origin is offset from
// the primary monitor when a secondary monitor sits left of / above
// the primary, so clamps must include SM_XVIRTUALSCREEN /
// SM_YVIRTUALSCREEN — not just the width/height of the union.
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let right = vx + vw;
let bottom = vy + vh;
if y + panel_h > bottom {
y = anchor.top - panel_h - 8;
}
if y < 0 {
y = anchor.top;
if y < vy {
y = anchor.top.max(vy);
}
if x + panel_w > virtual_screen_w {
x = virtual_screen_w - panel_w - 8;
if x + panel_w > right {
x = right - panel_w - 8;
}
if x < 0 {
x = 8;
if x < vx {
x = vx + 8;
}
(x, y)
}