Compare commits

..

1 Commits

Author SHA1 Message Date
652e451afe fix: prevent projects from getting stuck in starting/stopping state
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-linux (push) Successful in 5m46s
Sync Release to GitHub / sync-release (release) Successful in 1s
Build App / build-windows (push) Successful in 6m35s
Reconcile stale transient statuses on app startup, add Force Stop button
for transient states, and harden stop_project_container error handling so
Docker failures don't leave projects permanently stuck.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:12:49 -08:00
3 changed files with 36 additions and 10 deletions

View File

@@ -259,16 +259,18 @@ pub async fn stop_project_container(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(ref container_id) = project.container_id {
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
if let Some(ref container_id) = project.container_id {
// Close exec sessions for this project // Close exec sessions for this project
state.exec_manager.close_sessions_for_container(container_id).await; state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?; if let Err(e) = docker::stop_container(container_id).await {
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
}
} }
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(()) Ok(())
} }

View File

@@ -70,17 +70,38 @@ impl ProjectsStore {
(Vec::new(), false) (Vec::new(), false)
}; };
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {
match p.status {
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
log::warn!(
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
p.name,
p.id
);
p.status = crate::models::ProjectStatus::Stopped;
p.updated_at = chrono::Utc::now().to_rfc3339();
needs_save = true;
}
_ => {}
}
}
let store = Self { let store = Self {
projects: Mutex::new(projects), projects: Mutex::new(projects),
file_path, file_path,
}; };
// Persist migrated format back to disk // Persist migrated/reconciled format back to disk
if needs_save { if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format"); log::info!("Saving reconciled/migrated projects.json to disk");
let projects = store.lock(); let projects = store.lock();
if let Err(e) = store.save(&projects) { if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e); log::error!("Failed to save projects: {}", e);
} }
} }

View File

@@ -315,9 +315,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
</> </>
) : ( ) : (
<>
<span className="text-xs text-[var(--text-secondary)]"> <span className="text-xs text-[var(--text-secondary)]">
{project.status}... {project.status}...
</span> </span>
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
</>
)} )}
<ActionButton <ActionButton
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }} onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}