Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The pane could only ever watch a browser something else had published. This opens one: a URL and a viewport, launched inside the container and bound so the pane picks it up. Two uses, one action — a sign-in page, where the callback listener is *in* the container and the loop closes with no host round trip and no auth bridge, and a dev server on container loopback, which is how you watch a UI Claude is building. Reachable from both places the question comes up: "Open a page…" in the Browser tab, and an "In container" button on the terminal's URL prompt. Verified first, because it decided the design: a second client cannot join a bound browser. `chromium.connect()` against the published endpoint times out in every URL form (`ws+unix://…`, with and without the trailing path) — that socket speaks the dashboard's own transport, not the public connect protocol. Whoever launches is therefore the only process that can drive, so the helper is resident and holds the handle, and live resize applies to pages we opened and never to `@playwright/mcp`'s. Those take `--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the docs now say. The viewport is the interesting half. Resizing the *window* does nothing to the page — the viewer is a CDP screencast, so a bigger window is the same pixels drawn larger, which is why pages have been looking like they were rendered small. `page.setViewportSize()` genuinely reflows: measured against a `@media (max-width: 900px)` rule, it fires at 800×600 and clears at 1440×900. Match-window mode pushes the pop-out's settled size into it, debounced by generation counter because a drag emits `Resized` continuously and each one costs a container exec. Control is a polled JSON file in /tmp: no port, no second listener, nothing added to the proxy's surface, and URLs travel as argv to `node` so no shell ever parses one. A re-open with a helper already up navigates instead of relaunching — otherwise the second page would throw away the session the first one just signed into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
656 lines
28 KiB
Rust
656 lines
28 KiB
Rust
mod auth_bridge;
|
|
mod browser_view;
|
|
mod commands;
|
|
mod docker;
|
|
mod install_helper;
|
|
mod logging;
|
|
mod models;
|
|
mod storage;
|
|
pub mod web_terminal;
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use auth_bridge::AuthBridgeManager;
|
|
use docker::exec::ExecSessionManager;
|
|
use storage::projects_store::ProjectsStore;
|
|
use storage::settings_store::SettingsStore;
|
|
use tauri::async_runtime::JoinHandle;
|
|
use tauri::{Emitter, Manager};
|
|
use tokio::sync::watch;
|
|
use web_terminal::WebTerminalServer;
|
|
|
|
pub struct AppState {
|
|
pub projects_store: Arc<ProjectsStore>,
|
|
pub settings_store: Arc<SettingsStore>,
|
|
pub exec_manager: Arc<ExecSessionManager>,
|
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
|
pub lifecycle: Arc<Lifecycle>,
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Startup / shutdown coordination
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Total wall-clock budget for teardown before the process exits regardless.
|
|
///
|
|
/// Six teardown steps used to run *serially* inside a `block_on` on the
|
|
/// window-event thread with no timeout: two container stops at Docker's default
|
|
/// 10s grace, a `docker exec` per browser-view project, and every bollard call
|
|
/// inheriting a 120s client timeout. Quitting after Docker Desktop had already
|
|
/// gone away froze the window for minutes. Nothing here is worth more than a
|
|
/// few seconds of a user's exit.
|
|
const SHUTDOWN_BUDGET: Duration = Duration::from_secs(8);
|
|
|
|
/// How long the in-flight auto-start tasks get to notice cancellation before
|
|
/// they are aborted. They only have to reach their next await point.
|
|
const STARTUP_CANCEL_BUDGET: Duration = Duration::from_secs(3);
|
|
|
|
/// Backoff (seconds) between auto-start attempts. Docker Desktop routinely
|
|
/// takes 30-60s to accept API calls after login, which is exactly the window in
|
|
/// which Triple-C used to be launched, fail once, and stay broken for the whole
|
|
/// session.
|
|
const AUTOSTART_DELAYS: [u64; 8] = [0, 2, 4, 8, 15, 15, 30, 30];
|
|
|
|
/// Owns the "is the app going away?" signal and the handles of the background
|
|
/// tasks started during `setup`.
|
|
///
|
|
/// Both auto-starts are fire-and-forget, and quitting quickly used to race
|
|
/// them: `CloseRequested` stopped a gateway container that did not exist yet,
|
|
/// and the detached task then created and started it *after* the app was gone —
|
|
/// leaving an orphan proxy holding a provider key. The same shape orphaned the
|
|
/// web terminal, whose task wrote its server into the state slot that
|
|
/// `CloseRequested` had already `take()`-n. Shutdown therefore cancels and
|
|
/// waits for these tasks *before* running teardown, so teardown always sees the
|
|
/// final state of the world.
|
|
pub struct Lifecycle {
|
|
cancel: watch::Sender<bool>,
|
|
tasks: Mutex<Vec<JoinHandle<()>>>,
|
|
shutting_down: AtomicBool,
|
|
}
|
|
|
|
impl Lifecycle {
|
|
fn new() -> Self {
|
|
let (cancel, _) = watch::channel(false);
|
|
Self {
|
|
cancel,
|
|
tasks: Mutex::new(Vec::new()),
|
|
shutting_down: AtomicBool::new(false),
|
|
}
|
|
}
|
|
|
|
/// A receiver that flips to `true` when the app starts shutting down.
|
|
pub fn cancellation(&self) -> watch::Receiver<bool> {
|
|
self.cancel.subscribe()
|
|
}
|
|
|
|
pub fn is_shutting_down(&self) -> bool {
|
|
*self.cancel.borrow()
|
|
}
|
|
|
|
/// Register a startup task so shutdown can wait for it.
|
|
fn track(&self, handle: JoinHandle<()>) {
|
|
self.tasks
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.push(handle);
|
|
}
|
|
|
|
/// `true` the first time only — the window can emit `CloseRequested` again
|
|
/// once we ask the app to exit, and teardown must not restart.
|
|
fn begin_shutdown(&self) -> bool {
|
|
if self.shutting_down.swap(true, Ordering::SeqCst) {
|
|
return false;
|
|
}
|
|
// `send_replace`, not `send`: `send` reports an error *and leaves the
|
|
// value untouched* when nothing is subscribed, which is exactly the
|
|
// case when neither auto-start is enabled — and `is_shutting_down` (the
|
|
// web terminal's check) reads that stored value.
|
|
self.cancel.send_replace(true);
|
|
true
|
|
}
|
|
|
|
/// Let the tracked startup tasks unwind, then abort whatever is left.
|
|
async fn settle_startup_tasks(&self) {
|
|
let mut handles: Vec<JoinHandle<()>> = std::mem::take(
|
|
&mut *self.tasks.lock().unwrap_or_else(|e| e.into_inner()),
|
|
);
|
|
if handles.is_empty() {
|
|
return;
|
|
}
|
|
let settle = async {
|
|
for handle in &mut handles {
|
|
let _ = handle.await;
|
|
}
|
|
};
|
|
if tokio::time::timeout(STARTUP_CANCEL_BUDGET, settle).await.is_err() {
|
|
log::warn!("Startup tasks did not settle in time — aborting them");
|
|
for handle in &handles {
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run an auto-start until it succeeds, the app quits, or the retries run out.
|
|
///
|
|
/// Without this a launch that beats the Docker daemon (or Docker Desktop) to
|
|
/// readiness left the gateway and STT down for the entire session, with no
|
|
/// path back: nothing re-attempts them.
|
|
async fn autostart_with_retry<F, Fut>(label: &str, mut cancel: watch::Receiver<bool>, mut attempt: F)
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<(), String>>,
|
|
{
|
|
for (index, delay) in AUTOSTART_DELAYS.iter().enumerate() {
|
|
if *delay > 0 {
|
|
tokio::select! {
|
|
_ = cancel.changed() => return,
|
|
_ = tokio::time::sleep(Duration::from_secs(*delay)) => {}
|
|
}
|
|
}
|
|
if *cancel.borrow() {
|
|
return;
|
|
}
|
|
|
|
// Cancellation races the attempt itself, not just the backoff, so a
|
|
// quick quit isn't held up by an in-flight Docker call — and, more
|
|
// importantly, so the attempt cannot complete after teardown has run.
|
|
let result = tokio::select! {
|
|
_ = cancel.changed() => return,
|
|
r = attempt() => r,
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if index > 0 {
|
|
log::info!("{} auto-start succeeded on attempt {}", label, index + 1);
|
|
}
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let last = index + 1 == AUTOSTART_DELAYS.len();
|
|
if index == 0 {
|
|
log::warn!("{} auto-start failed ({}) — will retry", label, e);
|
|
} else if last {
|
|
log::error!("{} auto-start gave up after {} attempts: {}", label, index + 1, e);
|
|
} else {
|
|
log::debug!("{} auto-start attempt {} failed: {}", label, index + 1, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run() {
|
|
logging::init();
|
|
|
|
let projects_store = Arc::new(match ProjectsStore::new() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log::error!("Failed to initialize projects store: {}", e);
|
|
panic!("Failed to initialize projects store: {}", e);
|
|
}
|
|
});
|
|
let settings_store = Arc::new(match SettingsStore::new() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log::error!("Failed to initialize settings store: {}", e);
|
|
panic!("Failed to initialize settings store: {}", e);
|
|
}
|
|
});
|
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
|
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
|
let lifecycle = Arc::new(Lifecycle::new());
|
|
|
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
|
let projects_store_setup = projects_store.clone();
|
|
let settings_store_setup = settings_store.clone();
|
|
let exec_manager_setup = exec_manager.clone();
|
|
let lifecycle_setup = lifecycle.clone();
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_store::Builder::default().build())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.manage(AppState {
|
|
projects_store,
|
|
settings_store,
|
|
exec_manager,
|
|
auth_bridge,
|
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
|
lifecycle,
|
|
})
|
|
.setup(move |app| {
|
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
|
Ok(icon) => {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.set_icon(icon);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to load window icon: {}", e);
|
|
}
|
|
}
|
|
|
|
// Auto-start web terminal server if enabled in settings
|
|
let settings = settings_store_setup.get();
|
|
if settings.web_terminal.enabled {
|
|
if let Some(token) = &settings.web_terminal.access_token {
|
|
let token = token.clone();
|
|
let port = settings.web_terminal.port;
|
|
let exec_mgr = exec_manager_setup.clone();
|
|
let proj_store = projects_store_setup.clone();
|
|
let set_store = settings_store_setup.clone();
|
|
let state = app.state::<AppState>();
|
|
let web_server_mutex = state.web_terminal_server.clone();
|
|
let lifecycle = lifecycle_setup.clone();
|
|
|
|
let handle = tauri::async_runtime::spawn(async move {
|
|
match WebTerminalServer::start(
|
|
port,
|
|
token,
|
|
exec_mgr,
|
|
proj_store,
|
|
set_store,
|
|
)
|
|
.await
|
|
{
|
|
Ok(server) => {
|
|
// The app may have been asked to quit while the
|
|
// server was coming up, in which case teardown
|
|
// has already emptied this slot and would never
|
|
// look at it again. Stop it here instead of
|
|
// storing an orphan.
|
|
if lifecycle.is_shutting_down() {
|
|
server.stop();
|
|
log::info!("Web terminal stopped immediately: app is exiting");
|
|
return;
|
|
}
|
|
let mut guard = web_server_mutex.lock().await;
|
|
*guard = Some(server);
|
|
log::info!("Web terminal auto-started on port {}", port);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to auto-start web terminal: {}", e);
|
|
}
|
|
}
|
|
});
|
|
lifecycle_setup.track(handle);
|
|
}
|
|
}
|
|
|
|
// Auto-start STT container if enabled in settings
|
|
if settings.stt.enabled {
|
|
let stt_settings = settings.stt.clone();
|
|
let cancel = lifecycle_setup.cancellation();
|
|
let handle = tauri::async_runtime::spawn(async move {
|
|
autostart_with_retry("STT container", cancel, || async {
|
|
let status = docker::stt::ensure_stt_running(&stt_settings).await?;
|
|
if status.running {
|
|
log::info!("STT container auto-started on port {}", stt_settings.port);
|
|
Ok(())
|
|
} else {
|
|
Err("container not running after ensure_stt_running".to_string())
|
|
}
|
|
})
|
|
.await;
|
|
});
|
|
lifecycle_setup.track(handle);
|
|
}
|
|
|
|
// Auto-start model gateway container if enabled in settings
|
|
if settings.gateway.enabled {
|
|
let gateway_settings = settings.gateway.clone();
|
|
let cancel = lifecycle_setup.cancellation();
|
|
let handle = tauri::async_runtime::spawn(async move {
|
|
autostart_with_retry("Model gateway", cancel, || async {
|
|
let status =
|
|
docker::gateway::ensure_gateway_running(&gateway_settings).await?;
|
|
if status.running {
|
|
log::info!(
|
|
"Model gateway auto-started on port {}",
|
|
gateway_settings.port
|
|
);
|
|
Ok(())
|
|
} else {
|
|
Err("container not running after ensure_gateway_running".to_string())
|
|
}
|
|
})
|
|
.await;
|
|
});
|
|
lifecycle_setup.track(handle);
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.on_window_event(|window, event| {
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
// This handler fires for *every* window, and what follows stops
|
|
// containers and exits the process. Only the main window means
|
|
// that. Secondary windows — the browser view's pop-out — are
|
|
// closed and reopened freely and must just close.
|
|
if window.label() != "main" {
|
|
return;
|
|
}
|
|
|
|
let state = window.state::<AppState>();
|
|
let lifecycle = state.lifecycle.clone();
|
|
|
|
// Already shutting down: let the window close. That covers our
|
|
// own `exit` unwinding it, and it deliberately leaves a second
|
|
// click on the X as a force-quit — teardown is a courtesy, not
|
|
// a hostage situation.
|
|
if !lifecycle.begin_shutdown() {
|
|
return;
|
|
}
|
|
|
|
let exec_manager = state.exec_manager.clone();
|
|
let auth_bridge = state.auth_bridge.clone();
|
|
let web_terminal_server = state.web_terminal_server.clone();
|
|
drop(state);
|
|
|
|
// Teardown talks to Docker, so it cannot be instant. Keep the
|
|
// window alive and tell the UI what is happening rather than
|
|
// blocking the event thread on it and looking hung.
|
|
api.prevent_close();
|
|
let _ = window.emit("app-shutting-down", ());
|
|
|
|
let app_handle = window.app_handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
let teardown = async {
|
|
// First: let the auto-starts unwind. Anything they are
|
|
// midway through creating has to exist before the stops
|
|
// below run, or it outlives the app.
|
|
lifecycle.settle_startup_tasks().await;
|
|
|
|
// Then everything else, concurrently — these touch
|
|
// different subsystems and nothing here depends on
|
|
// another's result. Serially, the two container stops
|
|
// alone were 20s of Docker's default grace period.
|
|
let web_terminal = async {
|
|
if let Some(server) = web_terminal_server.lock().await.take() {
|
|
server.stop();
|
|
}
|
|
};
|
|
let stop_stt = async {
|
|
if let Err(e) = docker::stt::stop_stt_container().await {
|
|
log::warn!("Failed to stop the STT container on exit: {}", e);
|
|
}
|
|
};
|
|
let stop_gateway = async {
|
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
|
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
|
}
|
|
};
|
|
tokio::join!(
|
|
web_terminal,
|
|
stop_stt,
|
|
stop_gateway,
|
|
exec_manager.close_all_sessions(),
|
|
auth_bridge.stop_all(),
|
|
browser_view::manager().stop_all(),
|
|
);
|
|
};
|
|
|
|
if tokio::time::timeout(SHUTDOWN_BUDGET, teardown).await.is_err() {
|
|
log::warn!(
|
|
"Shutdown exceeded {}s — exiting with teardown incomplete",
|
|
SHUTDOWN_BUDGET.as_secs()
|
|
);
|
|
}
|
|
app_handle.exit(0);
|
|
});
|
|
}
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
// Docker
|
|
commands::docker_commands::check_docker,
|
|
commands::docker_commands::check_image_exists,
|
|
commands::docker_commands::build_image,
|
|
commands::docker_commands::get_container_info,
|
|
commands::docker_commands::list_sibling_containers,
|
|
// Projects
|
|
commands::project_commands::list_projects,
|
|
commands::project_commands::add_project,
|
|
commands::project_commands::remove_project,
|
|
commands::project_commands::update_project,
|
|
commands::project_commands::start_project_container,
|
|
commands::project_commands::stop_project_container,
|
|
commands::project_commands::rebuild_project_container,
|
|
commands::project_commands::reconcile_project_statuses,
|
|
// Container base-image migration
|
|
commands::migration_commands::get_container_staleness,
|
|
commands::migration_commands::migrate_project_to_base,
|
|
commands::migration_commands::confirm_migration,
|
|
commands::migration_commands::rollback_migration,
|
|
commands::migration_commands::get_migration_state,
|
|
// Auth bridge
|
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
|
commands::auth_bridge_commands::get_auth_bridge_status,
|
|
// Browser view (Playwright dashboard pane)
|
|
browser_view::commands::set_browser_view_enabled,
|
|
browser_view::commands::get_browser_view_status,
|
|
browser_view::commands::check_browser_view_support,
|
|
browser_view::commands::install_browser_view_support,
|
|
browser_view::commands::install_browser_view_browser,
|
|
browser_view::commands::open_browser_view_popout,
|
|
browser_view::commands::close_browser_view_popout,
|
|
browser_view::commands::get_browser_view_popout_state,
|
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
|
browser_view::commands::open_page_in_container_browser,
|
|
browser_view::commands::set_container_page_viewport,
|
|
browser_view::commands::get_container_page_state,
|
|
browser_view::commands::close_container_page,
|
|
browser_view::commands::set_browser_view_match_window,
|
|
browser_view::commands::get_browser_view_match_window,
|
|
// Shared Claude Code auth token
|
|
commands::auth_token_commands::acquire_claude_token,
|
|
commands::auth_token_commands::submit_claude_token_code,
|
|
commands::auth_token_commands::cancel_claude_token,
|
|
commands::auth_token_commands::has_claude_token,
|
|
commands::auth_token_commands::clear_claude_token,
|
|
// Settings
|
|
commands::settings_commands::get_settings,
|
|
commands::settings_commands::update_settings,
|
|
commands::settings_commands::pull_image,
|
|
commands::settings_commands::detect_aws_config,
|
|
commands::settings_commands::inspect_ca_cert_path,
|
|
commands::settings_commands::list_aws_profiles,
|
|
commands::settings_commands::detect_host_timezone,
|
|
// Terminal
|
|
commands::terminal_commands::open_terminal_session,
|
|
commands::terminal_commands::terminal_input,
|
|
commands::terminal_commands::terminal_resize,
|
|
commands::terminal_commands::close_terminal_session,
|
|
commands::terminal_commands::paste_image_to_terminal,
|
|
commands::terminal_commands::upload_host_file_to_terminal,
|
|
commands::terminal_commands::start_audio_bridge,
|
|
commands::terminal_commands::send_audio_data,
|
|
commands::terminal_commands::stop_audio_bridge,
|
|
// Files
|
|
commands::file_commands::list_container_files,
|
|
commands::file_commands::download_container_file,
|
|
commands::file_commands::download_container_backup,
|
|
commands::file_commands::upload_file_to_container,
|
|
// AWS
|
|
commands::aws_commands::aws_sso_refresh,
|
|
// Updates
|
|
commands::update_commands::get_app_version,
|
|
commands::update_commands::check_for_updates,
|
|
commands::update_commands::check_image_update,
|
|
// Help
|
|
commands::help_commands::get_help_content,
|
|
// Install helper
|
|
commands::install_helper_commands::detect_install_options,
|
|
commands::install_helper_commands::run_docker_install,
|
|
// Web Terminal
|
|
commands::web_terminal_commands::start_web_terminal,
|
|
commands::web_terminal_commands::stop_web_terminal,
|
|
commands::web_terminal_commands::get_web_terminal_status,
|
|
commands::web_terminal_commands::regenerate_web_terminal_token,
|
|
// STT
|
|
commands::stt_commands::get_stt_status,
|
|
commands::stt_commands::start_stt,
|
|
commands::stt_commands::stop_stt,
|
|
commands::stt_commands::build_stt_image,
|
|
commands::stt_commands::pull_stt_image,
|
|
commands::stt_commands::transcribe_audio,
|
|
// Model gateway (LiteLLM)
|
|
commands::gateway_commands::get_gateway_status,
|
|
commands::gateway_commands::start_gateway,
|
|
commands::gateway_commands::stop_gateway,
|
|
commands::gateway_commands::check_gateway_health,
|
|
commands::gateway_commands::build_gateway_image,
|
|
commands::gateway_commands::pull_gateway_image,
|
|
commands::gateway_commands::set_gateway_api_key,
|
|
commands::gateway_commands::clear_gateway_api_key,
|
|
commands::gateway_commands::get_gateway_auth_token,
|
|
commands::gateway_commands::regenerate_gateway_auth_token,
|
|
// Container introspection (sessions / capabilities / scheduler)
|
|
commands::inspect_commands::list_claude_sessions,
|
|
commands::inspect_commands::resume_session_command,
|
|
commands::inspect_commands::list_container_capabilities,
|
|
commands::inspect_commands::list_scheduled_tasks,
|
|
commands::inspect_commands::add_scheduled_task,
|
|
commands::inspect_commands::update_scheduled_task,
|
|
commands::inspect_commands::get_scheduled_task_log,
|
|
commands::inspect_commands::set_scheduled_task_enabled,
|
|
commands::inspect_commands::run_scheduled_task_now,
|
|
commands::inspect_commands::remove_scheduled_task,
|
|
commands::inspect_commands::get_scheduler_notifications,
|
|
commands::inspect_commands::clear_scheduler_notifications,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::AtomicUsize;
|
|
|
|
/// Drives the retry loop under a paused clock, so the real backoff schedule
|
|
/// is exercised without waiting for it.
|
|
async fn run_autostart(
|
|
cancel: watch::Receiver<bool>,
|
|
outcomes: Vec<Result<(), String>>,
|
|
) -> usize {
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let counter = calls.clone();
|
|
let outcomes = Arc::new(Mutex::new(outcomes.into_iter()));
|
|
autostart_with_retry("test", cancel, move || {
|
|
let counter = counter.clone();
|
|
let outcomes = outcomes.clone();
|
|
async move {
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
outcomes
|
|
.lock()
|
|
.unwrap()
|
|
.next()
|
|
.unwrap_or(Err("still down".to_string()))
|
|
}
|
|
})
|
|
.await;
|
|
calls.load(Ordering::SeqCst)
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_working_autostart_runs_exactly_once() {
|
|
let (_tx, rx) = watch::channel(false);
|
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 1);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn an_autostart_that_beat_docker_to_readiness_recovers() {
|
|
// The regression: Docker not being up yet used to cost the whole
|
|
// session — gateway down, STT down, and nothing ever retried.
|
|
let (_tx, rx) = watch::channel(false);
|
|
let calls = run_autostart(
|
|
rx,
|
|
vec![
|
|
Err("daemon not running".to_string()),
|
|
Err("daemon not running".to_string()),
|
|
Ok(()),
|
|
],
|
|
)
|
|
.await;
|
|
assert_eq!(calls, 3);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_permanently_failing_autostart_gives_up_rather_than_looping_forever() {
|
|
let (_tx, rx) = watch::channel(false);
|
|
assert_eq!(
|
|
run_autostart(rx, vec![]).await,
|
|
AUTOSTART_DELAYS.len(),
|
|
"should attempt once per backoff step and then stop"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_quick_quit_stops_the_retries_before_they_start() {
|
|
// Quitting before the first attempt must not leave a task that creates
|
|
// and starts a container after teardown has already run.
|
|
let (tx, rx) = watch::channel(false);
|
|
tx.send(true).unwrap();
|
|
assert_eq!(run_autostart(rx, vec![Ok(())]).await, 0);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cancelling_between_attempts_stops_the_retries() {
|
|
let (tx, rx) = watch::channel(false);
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let counter = calls.clone();
|
|
autostart_with_retry("test", rx, move || {
|
|
let counter = counter.clone();
|
|
let tx = tx.clone();
|
|
async move {
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
// The app starts quitting while this attempt is in flight.
|
|
let _ = tx.send(true);
|
|
Err("daemon not running".to_string())
|
|
}
|
|
})
|
|
.await;
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_begins_exactly_once() {
|
|
// `CloseRequested` fires again when our own `exit(0)` unwinds the
|
|
// window; teardown must not start a second time.
|
|
let lifecycle = Lifecycle::new();
|
|
assert!(!lifecycle.is_shutting_down());
|
|
assert!(lifecycle.begin_shutdown());
|
|
assert!(lifecycle.is_shutting_down());
|
|
assert!(!lifecycle.begin_shutdown());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn beginning_shutdown_notifies_already_running_startup_tasks() {
|
|
let lifecycle = Lifecycle::new();
|
|
let mut cancel = lifecycle.cancellation();
|
|
assert!(!*cancel.borrow());
|
|
lifecycle.begin_shutdown();
|
|
assert!(cancel.changed().await.is_ok());
|
|
assert!(*cancel.borrow());
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_startup_task_that_ignores_cancellation_is_abandoned_not_awaited() {
|
|
// The budget is what keeps a wedged auto-start from turning quit into a
|
|
// multi-minute freeze.
|
|
let lifecycle = Lifecycle::new();
|
|
lifecycle.track(tauri::async_runtime::spawn(async {
|
|
tokio::time::sleep(Duration::from_secs(600)).await;
|
|
}));
|
|
lifecycle.begin_shutdown();
|
|
let started = tokio::time::Instant::now();
|
|
lifecycle.settle_startup_tasks().await;
|
|
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
|
}
|
|
}
|