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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ features = [
"Win32_System_Registry",
"Win32_System_LibraryLoader",
"Win32_Graphics_Gdi",
"Win32_Graphics_DirectWrite",
]

[profile.release]
Expand Down
129 changes: 129 additions & 0 deletions src/boot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Boot-time readiness gating.
//!
//! When the app is launched at logon via the HKCU Run key, parts of the Windows
//! graphics/shell stack are not necessarily ready yet. In particular,
//! gpui::Application::new() constructs WindowsPlatform::new(), which creates a
//! shared DirectWrite factory (DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED))
//! and enumerates the system font collection (GetSystemFontCollection). Both of
//! those depend on the Windows Font Cache Service (FontCache), which is often
//! still StartPending in the first seconds of a session.
//!
//! When they fail, GPUI calls show_error("Failed to launch", ...) a
//! MB_ICONERROR | MB_SYSTEMMODAL message box — and then .unwrap() panics, so the
//! process aborts and the app never starts. That is the "Windows error message box at
//! startup" symptom.
//!
//! To remove the race we gate GPUI construction on the *same* DirectWrite calls
//! succeeding here first, with backoff. On a manual launch the font subsystem is
//! already up, so the probe succeeds on the first attempt and we return immediately.

use std::time::Duration;

/// Number of probe attempts before giving up (120 × 250ms ≈ 30s).
const FONT_WAIT_ATTEMPTS: u32 = 120;
/// Delay between probe attempts.
const FONT_WAIT_DELAY: Duration = Duration::from_millis(250);

/// Polls probe until it returns Ok, sleeping delay between attempts, up to
/// max_attempts times.
///
/// Returns Ok(attempt_count) on the first success, or Err(last_error) if every
/// attempt failed (or max_attempts is 0). This is a pure control-flow harness with
/// the OS call injected, so it can be unit-tested deterministically.
pub fn poll_until_ready<F>(max_attempts: u32, delay: Duration, mut probe: F) -> Result<u32, String>
where
F: FnMut() -> Result<(), String>,
{
let mut last_error = String::from("probe never ran");
for attempt in 1..=max_attempts {
match probe() {
Ok(()) => return Ok(attempt),
Err(e) => {
last_error = e;
if attempt < max_attempts {
std::thread::sleep(delay);
}
}
}
}
Err(last_error)
}

/// Probes the DirectWrite font subsystem once, mirroring the Font-Cache-dependent
/// calls GPUI makes during WindowsPlatform::new(): create a shared factory and
/// enumerate the system font collection.
fn probe_font_subsystem() -> Result<(), String> {
use windows::Win32::Graphics::DirectWrite::{
DWriteCreateFactory, IDWriteFactory, IDWriteFontCollection, DWRITE_FACTORY_TYPE_SHARED,
};

// SAFETY: DWriteCreateFactory returns an owned COM interface on success.
// GetSystemFontCollection is a read-only query; we pass a valid out-pointer and
// only read the returned interface. No raw pointers escape this block.
unsafe {
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)
.map_err(|e| format!("DWriteCreateFactory(shared) failed: {e}"))?;

let mut collection: Option<IDWriteFontCollection> = None;
factory
.GetSystemFontCollection(&mut collection, false)
.map_err(|e| format!("GetSystemFontCollection failed: {e}"))?;

match collection {
// A populated collection means the Font Cache Service answered.
Some(c) if c.GetFontFamilyCount() > 0 => Ok(()),
Some(_) => Err("system font collection is empty".to_string()),
None => Err("system font collection was null".to_string()),
}
}
}

/// Blocks until the DirectWrite font subsystem is ready, or a ~30s timeout elapses.
///
/// Must be called before gpui::Application::new(). See the module docs for why.
/// If the timeout is hit we proceed anyway (GPUI may then show its own error box),
/// having recorded the last error to the startup log for diagnosis.
pub fn wait_for_font_subsystem() {
match poll_until_ready(FONT_WAIT_ATTEMPTS, FONT_WAIT_DELAY, probe_font_subsystem) {
// The common case (manual launch / fast boot): ready immediately, no log noise.
Ok(1) => {}
Ok(attempts) => {
let waited_ms = (attempts - 1) * FONT_WAIT_DELAY.as_millis() as u32;
log_startup(&format!(
"Font subsystem ready after {attempts} attempt(s) (~{waited_ms}ms wait)."
));
}
Err(last_error) => {
let timeout_s = FONT_WAIT_ATTEMPTS as u128 * FONT_WAIT_DELAY.as_millis() / 1000;
log_startup(&format!(
"Font subsystem NOT ready after ~{timeout_s}s; constructing GPUI anyway. \
Last error: {last_error}"
));
}
}
}

/// Appends a timestamped line to %APPDATA%\Polterdesk\startup.log. Best-effort;
/// any failure is ignored (we must never block startup on logging).
fn log_startup(msg: &str) {
use std::io::Write;

let Ok(appdata) = std::env::var("APPDATA") else {
return;
};
let dir = std::path::PathBuf::from(appdata).join("Polterdesk");
let _ = std::fs::create_dir_all(&dir);

let unix_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);

if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("startup.log"))
{
let _ = writeln!(f, "[{unix_secs}] {msg}");
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod app_state;
pub mod boot;
pub mod desktop;
pub mod settings;
pub mod startup;
Expand Down
10 changes: 9 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![windows_subsystem = "windows"]

mod app_state;
mod boot;
mod desktop;
mod settings;
mod startup;
Expand Down Expand Up @@ -41,9 +42,16 @@ fn main() {

let state = AppState::new(settings.clone(), gpui_tx);

// Spawn the WinAPI background thread (hotkey, tray, mouse hook)
// Spawn the WinAPI background thread (hotkey, tray, mouse hook).
// Done before the font wait so the tray icon appears promptly at boot.
let _winapi_handle = winapi_thread::spawn(state.clone());

// At logon the Windows Font Cache Service may still be starting, which makes
// gpui::Application::new() -> WindowsPlatform::new() fail in DirectWrite and show a
// system-modal "Failed to launch" error box, then abort. Gate GPUI construction on
// the font subsystem actually being ready. See boot::wait_for_font_subsystem.
boot::wait_for_font_subsystem();

// Start the GPUI application on the main thread
gpui::Application::new().run(move |cx| {
ui::theme::apply_theme(cx);
Expand Down
57 changes: 57 additions & 0 deletions tests/boot_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use polterdesk::boot::poll_until_ready;
use std::cell::Cell;
use std::time::Duration;

// The boot-readiness retry harness gates GPUI construction on the DirectWrite
// font subsystem becoming ready at logon. The OS probe is injected, so these
// tests exercise the control flow deterministically with a zero delay.

#[test]
fn returns_on_first_success_without_retrying() {
let calls = Cell::new(0);
let result = poll_until_ready(5, Duration::ZERO, || {
calls.set(calls.get() + 1);
Ok(())
});
assert_eq!(result, Ok(1));
assert_eq!(calls.get(), 1, "must not probe again after first success");
}

#[test]
fn retries_until_probe_succeeds() {
let calls = Cell::new(0);
let result = poll_until_ready(10, Duration::ZERO, || {
let n = calls.get() + 1;
calls.set(n);
if n >= 3 {
Ok(())
} else {
Err(format!("font subsystem not ready (attempt {n})"))
}
});
assert_eq!(result, Ok(3));
assert_eq!(calls.get(), 3);
}

#[test]
fn gives_up_after_max_attempts_returning_last_error() {
let calls = Cell::new(0);
let result = poll_until_ready(4, Duration::ZERO, || {
let n = calls.get() + 1;
calls.set(n);
Err(format!("boom {n}"))
});
assert_eq!(result, Err("boom 4".to_string()));
assert_eq!(calls.get(), 4, "must attempt exactly max_attempts times");
}

#[test]
fn zero_max_attempts_never_probes() {
let calls = Cell::new(0);
let result = poll_until_ready(0, Duration::ZERO, || {
calls.set(calls.get() + 1);
Ok(())
});
assert!(result.is_err());
assert_eq!(calls.get(), 0);
}
Loading