fix(security): address automated code review findings
All checks were successful
Test / frontend-typecheck (pull_request) Successful in 1m49s
Test / frontend-tests (pull_request) Successful in 1m46s
PR Review Automation / review (pull_request) Successful in 4m24s
Test / rust-fmt-check (pull_request) Successful in 12m1s
Test / rust-clippy (pull_request) Successful in 13m46s
Test / rust-tests (pull_request) Successful in 15m8s

BLOCKER fixes:
- Implement create_azuredevops_workitem instead of returning a stub error,
  reusing the existing create_work_item integration helper and writing an
  audit-log entry on success.
- Log kill failures in PtySession::Drop so leaked child processes surface
  in tracing rather than being silently swallowed.
- Add explicit PTY cleanup on every exit path of run_session_io (process
  exit, read error, write error, resize error, terminate command).
- Treat PTY resize failures as fatal: emit terminal-error to the frontend
  and break the session loop instead of just warning.

WARNING fixes:
- Remove the dead extract_json_path_value helper from commands/kube.rs.
- Wrap temp kubeconfig files in commands/metrics.rs in an RAII guard
  (TempKubeconfig) so they're removed on early-return / panic paths.
- Wrap temp kubeconfig files in commands/shell.rs PTY-session starters
  in a disarmable RAII guard (KubeconfigGuard); if kubectl resolution
  fails we no longer leak the file.
- Drop the `clear;` prefix from the kubectl-exec shell fallback so
  containers without `clear`/`tput` don't print a confusing error.

SUGGESTION fixes:
- Document why node CPU/memory percentages are 0.0 in metrics::client
  and link the gap to future work fetching node capacity.
- Add a module-level doc comment to AppState describing the
  synchronization expectations (std vs tokio Mutex) for each public
  field, and warn against holding std::sync MutexGuards across .await.

Verified: cargo fmt --check, cargo clippy -- -D warnings, and
cargo test (377 passed, 6 ignored) all pass.
This commit is contained in:
Shaun Arman 2026-06-09 18:08:58 -05:00
parent 44d33035de
commit 9ae89bf487
8 changed files with 320 additions and 117 deletions

View File

@ -277,11 +277,116 @@ pub async fn test_azuredevops_connection(
#[tauri::command] #[tauri::command]
pub async fn create_azuredevops_workitem( pub async fn create_azuredevops_workitem(
_issue_id: String, issue_id: String,
_project: String, project: String,
_config: serde_json::Value, config: serde_json::Value,
app_state: State<'_, AppState>,
) -> Result<TicketResult, String> { ) -> Result<TicketResult, String> {
Err("Integrations available in v0.2. Please update to the latest version.".to_string()) // Extract optional configuration values from the config payload.
// The frontend may pass: base_url, work_item_type, severity. All have safe defaults.
let base_url = config
.get("base_url")
.and_then(|v| v.as_str())
.map(String::from);
let work_item_type = config
.get("work_item_type")
.and_then(|v| v.as_str())
.unwrap_or("Bug")
.to_string();
let severity = config
.get("severity")
.and_then(|v| v.as_str())
.unwrap_or("3 - Medium")
.to_string();
// Look up issue title/description from the database to use as work-item content.
let (title, description, base_url_resolved) = {
let db = app_state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {e}"))?;
let (title, description) = db
.query_row(
"SELECT title, description FROM issues WHERE id = ?1",
rusqlite::params![issue_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(|e| format!("Failed to load issue {issue_id}: {e}"))?;
// Fall back to stored integration_config base_url if caller did not provide one.
let resolved = match base_url {
Some(url) => url,
None => db
.query_row(
"SELECT base_url FROM integration_config WHERE service = 'azuredevops'",
[],
|row| row.get::<_, String>(0),
)
.map_err(|e| format!("Azure DevOps base URL not configured: {e}"))?,
};
(title, description, resolved)
};
// Retrieve and decrypt stored access token.
let access_token = {
let db = app_state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {e}"))?;
let encrypted: String = db
.query_row(
"SELECT encrypted_token FROM credentials WHERE service = 'azuredevops'",
[],
|row| row.get(0),
)
.map_err(|e| {
format!("Azure DevOps credentials not found. Please authenticate first: {e}")
})?;
crate::integrations::auth::decrypt_token(&encrypted)?
};
let ado_config = crate::integrations::azuredevops::AzureDevOpsConfig {
organization_url: base_url_resolved,
project,
access_token,
};
let result = crate::integrations::azuredevops::create_work_item(
&ado_config,
&title,
&description,
&work_item_type,
&severity,
)
.await?;
// Audit log the external publish action.
{
let db = app_state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {e}"))?;
let details = serde_json::json!({
"issue_id": issue_id,
"work_item_id": result.id,
"work_item_type": work_item_type,
});
if let Err(e) = crate::audit::log::write_audit_event(
&db,
"ado_workitem_created",
"issue",
&issue_id,
&details.to_string(),
) {
tracing::warn!("Failed to write audit event for ADO workitem creation: {e}");
}
}
Ok(result)
} }
// ─── OAuth2 Commands ──────────────────────────────────────────────────────── // ─── OAuth2 Commands ────────────────────────────────────────────────────────

View File

@ -6418,66 +6418,6 @@ pub async fn list_custom_resources(
parse_custom_resources_json(&output_str) parse_custom_resources_json(&output_str)
} }
/// Simple JSONPath-like extractor for custom resource fields.
/// Supports basic paths like .status.phase, .spec.replicas, .metadata.labels['app']
#[allow(dead_code)]
fn extract_json_path_value(item: &Value, json_path: &str) -> String {
// Remove leading dot if present
let path = json_path.strip_prefix('.').unwrap_or(json_path);
// Split path by dots and traverse
let parts: Vec<&str> = path.split('.').collect();
let mut current = item;
for part in parts {
// Handle array access like status[0] or map access like labels['app']
if let Some(bracket_start) = part.find('[') {
let field = &part[..bracket_start];
current = match current.get(field) {
Some(v) => v,
None => return "N/A".to_string(),
};
// Extract index or key from brackets
if let Some(bracket_end) = part.find(']') {
let accessor = &part[bracket_start + 1..bracket_end];
current = if accessor.starts_with('\'') || accessor.starts_with('"') {
// Map key access
let key = accessor.trim_matches(|c| c == '\'' || c == '"');
match current.get(key) {
Some(v) => v,
None => return "N/A".to_string(),
}
} else {
// Array index access
match accessor.parse::<usize>() {
Ok(idx) => match current.as_array().and_then(|a| a.get(idx)) {
Some(v) => v,
None => return "N/A".to_string(),
},
Err(_) => return "N/A".to_string(),
}
};
}
} else {
current = match current.get(part) {
Some(v) => v,
None => return "N/A".to_string(),
};
}
}
// Convert final value to string
match current {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "".to_string(),
Value::Array(a) => format!("[{} items]", a.len()),
Value::Object(_) => "{object}".to_string(),
}
}
fn parse_custom_resources_json(json_str: &str) -> Result<Vec<CustomResourceInfo>, String> { fn parse_custom_resources_json(json_str: &str) -> Result<Vec<CustomResourceInfo>, String> {
let value: Value = serde_json::from_str(json_str) let value: Value = serde_json::from_str(json_str)
.map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?;

View File

@ -2,6 +2,56 @@ use crate::metrics::{NodeMetrics, PodMetrics};
use crate::state::AppState; use crate::state::AppState;
use tauri::State; use tauri::State;
/// RAII guard that removes a temp kubeconfig file when dropped.
///
/// Using a Drop-based guard guarantees the sensitive kubeconfig is removed
/// even on panic or early `?` return — manual `remove_file` calls only run
/// on the happy path and were silently leaking the file on errors.
struct TempKubeconfig(std::path::PathBuf);
impl TempKubeconfig {
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempKubeconfig {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.0) {
// Only log when the file actually existed; NotFound is expected on
// Windows when the path was never written.
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"Failed to remove temp kubeconfig {}: {}",
self.0.display(),
e
);
}
}
}
}
/// Write the kubeconfig content to a unique temp file with 0600 permissions
/// and return an RAII guard that cleans up on drop.
fn write_temp_kubeconfig(content: &str) -> Result<TempKubeconfig, String> {
let path =
std::env::temp_dir().join(format!("kubeconfig-metrics-{}.yaml", uuid::Uuid::now_v7()));
let guard = TempKubeconfig(path);
std::fs::write(guard.path(), content.as_bytes())
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
// Ensure owner-only permissions (0600 on Unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(guard.path(), std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set kubeconfig permissions: {e}"))?;
}
Ok(guard)
}
/// Get pod metrics from kubectl top pods /// Get pod metrics from kubectl top pods
#[tauri::command] #[tauri::command]
pub async fn get_pod_metrics( pub async fn get_pod_metrics(
@ -14,20 +64,9 @@ pub async fn get_pod_metrics(
.get(&cluster_id) .get(&cluster_id)
.ok_or_else(|| "Cluster not found".to_string())?; .ok_or_else(|| "Cluster not found".to_string())?;
// Write temp kubeconfig // Write temp kubeconfig (auto-removed on drop)
let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let kubeconfig_content = cluster.kubeconfig_content.as_ref();
let temp_path = let kubeconfig = write_temp_kubeconfig(kubeconfig_content)?;
std::env::temp_dir().join(format!("kubeconfig-metrics-{}.yaml", uuid::Uuid::now_v7()));
std::fs::write(&temp_path, kubeconfig_content.as_bytes())
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
// Ensure owner-only permissions (0600 on Unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set kubeconfig permissions: {e}"))?;
}
// Run kubectl top pods with JSON output // Run kubectl top pods with JSON output
let args = vec![ let args = vec![
@ -39,14 +78,11 @@ pub async fn get_pod_metrics(
"-o".to_string(), "-o".to_string(),
"json".to_string(), "json".to_string(),
"--kubeconfig".to_string(), "--kubeconfig".to_string(),
temp_path.to_string_lossy().to_string(), kubeconfig.path().to_string_lossy().to_string(),
]; ];
let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?; let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
if output.exit_code != 0 { if output.exit_code != 0 {
return Err(format!("kubectl top pods failed: {}", output.stderr)); return Err(format!("kubectl top pods failed: {}", output.stderr));
} }
@ -54,6 +90,7 @@ pub async fn get_pod_metrics(
let json_output = &output.stdout; let json_output = &output.stdout;
crate::metrics::client::parse_pod_metrics(json_output) crate::metrics::client::parse_pod_metrics(json_output)
.map_err(|e| format!("Failed to parse pod metrics: {e}")) .map_err(|e| format!("Failed to parse pod metrics: {e}"))
// kubeconfig dropped here, file removed
} }
/// Get node metrics from kubectl top nodes /// Get node metrics from kubectl top nodes
@ -67,20 +104,9 @@ pub async fn get_node_metrics(
.get(&cluster_id) .get(&cluster_id)
.ok_or_else(|| "Cluster not found".to_string())?; .ok_or_else(|| "Cluster not found".to_string())?;
// Write temp kubeconfig // Write temp kubeconfig (auto-removed on drop)
let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let kubeconfig_content = cluster.kubeconfig_content.as_ref();
let temp_path = let kubeconfig = write_temp_kubeconfig(kubeconfig_content)?;
std::env::temp_dir().join(format!("kubeconfig-metrics-{}.yaml", uuid::Uuid::now_v7()));
std::fs::write(&temp_path, kubeconfig_content.as_bytes())
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
// Ensure owner-only permissions (0600 on Unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set kubeconfig permissions: {e}"))?;
}
// Run kubectl top nodes with JSON output // Run kubectl top nodes with JSON output
let args = vec![ let args = vec![
@ -90,14 +116,11 @@ pub async fn get_node_metrics(
"-o".to_string(), "-o".to_string(),
"json".to_string(), "json".to_string(),
"--kubeconfig".to_string(), "--kubeconfig".to_string(),
temp_path.to_string_lossy().to_string(), kubeconfig.path().to_string_lossy().to_string(),
]; ];
let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?; let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
if output.exit_code != 0 { if output.exit_code != 0 {
return Err(format!("kubectl top nodes failed: {}", output.stderr)); return Err(format!("kubectl top nodes failed: {}", output.stderr));
} }
@ -105,4 +128,5 @@ pub async fn get_node_metrics(
let json_output = &output.stdout; let json_output = &output.stdout;
crate::metrics::client::parse_node_metrics(json_output) crate::metrics::client::parse_node_metrics(json_output)
.map_err(|e| format!("Failed to parse node metrics: {e}")) .map_err(|e| format!("Failed to parse node metrics: {e}"))
// kubeconfig dropped here, file removed
} }

View File

@ -12,6 +12,41 @@ use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
/// RAII guard for a temp kubeconfig file. Removes the file when dropped
/// unless `disarm()` has been called — used on the error path of session
/// start so the file isn't leaked if kubectl resolution or session
/// registration fails after we've written it. On the success path we call
/// `disarm()` and the PTY session itself becomes responsible for the file's
/// lifetime (it lives in `std::env::temp_dir()` which is OS-cleaned).
struct KubeconfigGuard {
path: Option<std::path::PathBuf>,
}
impl KubeconfigGuard {
fn new(path: std::path::PathBuf) -> Self {
Self { path: Some(path) }
}
/// Transfer ownership: caller is now responsible for the file.
/// Returns the path string for use with the PTY session.
fn disarm(mut self) -> String {
let path = self.path.take().expect("KubeconfigGuard already disarmed");
path.to_string_lossy().into_owned()
}
}
impl Drop for KubeconfigGuard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("Failed to remove temp kubeconfig {}: {}", path.display(), e);
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandExecution { pub struct CommandExecution {
pub id: String, pub id: String,
@ -279,8 +314,9 @@ pub async fn start_pty_exec_session(
pod: String, pod: String,
container: Option<String>, container: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// Get active kubeconfig // Get active kubeconfig — the guard ensures the temp file is removed
let kubeconfig_path = { // if anything between here and `disarm()` fails.
let kubeconfig_guard: Option<KubeconfigGuard> = {
let db = state.db.lock().map_err(|e| e.to_string())?; let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db let mut stmt = db
.prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1") .prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1")
@ -298,16 +334,19 @@ pub async fn start_pty_exec_session(
std::fs::write(&temp_path, content) std::fs::write(&temp_path, content)
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?; .map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
Some(temp_path.to_string_lossy().to_string()) Some(KubeconfigGuard::new(temp_path))
} else { } else {
None None
} }
}; };
// Locate kubectl // Locate kubectl — if this fails, the guard cleans up the temp kubeconfig.
let kubectl_path = let kubectl_path =
crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?; crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?;
// Transfer ownership: PTY session now owns the temp file's lifetime.
let kubeconfig_path = kubeconfig_guard.map(|g| g.disarm());
// Start session // Start session
let params = crate::shell::session::SessionParams { let params = crate::shell::session::SessionParams {
cluster_id, cluster_id,
@ -337,8 +376,9 @@ pub async fn start_pty_attach_session(
pod: String, pod: String,
container: Option<String>, container: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
// Get active kubeconfig // Get active kubeconfig — the guard ensures the temp file is removed
let kubeconfig_path = { // if anything between here and `disarm()` fails.
let kubeconfig_guard: Option<KubeconfigGuard> = {
let db = state.db.lock().map_err(|e| e.to_string())?; let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db let mut stmt = db
.prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1") .prepare("SELECT encrypted_content FROM kubeconfig_files WHERE is_active = 1 LIMIT 1")
@ -356,16 +396,19 @@ pub async fn start_pty_attach_session(
std::fs::write(&temp_path, content) std::fs::write(&temp_path, content)
.map_err(|e| format!("Failed to write kubeconfig: {e}"))?; .map_err(|e| format!("Failed to write kubeconfig: {e}"))?;
Some(temp_path.to_string_lossy().to_string()) Some(KubeconfigGuard::new(temp_path))
} else { } else {
None None
} }
}; };
// Locate kubectl // Locate kubectl — if this fails, the guard cleans up the temp kubeconfig.
let kubectl_path = let kubectl_path =
crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?; crate::shell::kubectl::locate_kubectl().map_err(|e| format!("kubectl not found: {e}"))?;
// Transfer ownership: PTY session now owns the temp file's lifetime.
let kubeconfig_path = kubeconfig_guard.map(|g| g.disarm());
// Start session // Start session
let params = crate::shell::session::SessionParams { let params = crate::shell::session::SessionParams {
cluster_id, cluster_id,

View File

@ -139,9 +139,18 @@ pub fn parse_node_metrics(json_output: &str) -> Result<Vec<NodeMetrics>> {
.unwrap_or("0") .unwrap_or("0")
.to_string(); .to_string();
// Calculate percentages (simplified - would need capacity from kubectl get nodes) // Calculate percentages (simplified - would need capacity from kubectl get nodes).
let cpu_percent = 0.0; // TODO: Calculate from capacity //
let memory_percent = 0.0; // TODO: Calculate from capacity // TODO(metrics): Populate these from node `status.capacity` once we add
// a second kubectl call to fetch node capacity. The metrics-server JSON
// returned by `kubectl top nodes` only reports raw `usage` (cpu in
// nanocores, memory in Ki), not the node's allocatable totals, so we
// cannot compute a real percentage from this response alone.
// Until that work is done these are reported as 0.0 and the frontend
// hides the percent column. Tracking issue: see Telemetry/Metrics
// backlog in the project tracker.
let cpu_percent = 0.0;
let memory_percent = 0.0;
metrics.push(NodeMetrics { metrics.push(NodeMetrics {
name, name,

View File

@ -12,7 +12,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::{Read, Write}; use std::io::{Read, Write};
use tracing::debug; use tracing::{debug, warn};
/// PTY session handle with I/O streams /// PTY session handle with I/O streams
pub struct PtySession { pub struct PtySession {
@ -81,11 +81,15 @@ impl PtySession {
args.push(c.to_string()); args.push(c.to_string());
} }
// Use FreeLens-style shell fallback command // Use FreeLens-style shell fallback command.
// We deliberately omit `clear` from the chain: when a container image
// lacks `clear` (or `tput`), running it would print a non-fatal but
// confusing error to the user. The frontend terminal is responsible
// for clearing on connect.
args.push("--".to_string()); args.push("--".to_string());
args.push("sh".to_string()); args.push("sh".to_string());
args.push("-c".to_string()); args.push("-c".to_string());
args.push("clear; (bash || ash || sh)".to_string()); args.push("bash || ash || sh".to_string());
let mut env = Vec::new(); let mut env = Vec::new();
if let Some(kubeconfig) = kubeconfig_path { if let Some(kubeconfig) = kubeconfig_path {
@ -199,9 +203,12 @@ impl PtySession {
impl Drop for PtySession { impl Drop for PtySession {
fn drop(&mut self) { fn drop(&mut self) {
// Best-effort cleanup // Best-effort cleanup. Log kill failures rather than swallowing them so
// operators can detect leaked child processes during diagnostics.
if self.is_alive() { if self.is_alive() {
let _ = self.kill(); if let Err(e) = self.kill() {
warn!("PTY session Drop: failed to kill child process: {e:#}");
}
} }
debug!("PTY session dropped"); debug!("PTY session dropped");
} }

View File

@ -202,6 +202,26 @@ impl SessionManager {
) -> Result<()> { ) -> Result<()> {
let mut poll_interval = interval(Duration::from_millis(50)); let mut poll_interval = interval(Duration::from_millis(50));
// Explicit cleanup helper invoked on every exit path. While
// `PtySession::Drop` already best-effort kills the child, doing it here
// first lets us log the outcome and surface failures via tracing.
// After this returns, the PtySession is consumed and dropped, releasing
// the master/slave PTY handles.
let cleanup = |pty: &mut PtySession, session_id: &str, reason: &str| {
debug!(
"Cleaning up PTY for session {} (reason: {})",
session_id, reason
);
if pty.is_alive() {
if let Err(e) = pty.kill() {
warn!(
"Failed to kill PTY child for session {} during cleanup: {}",
session_id, e
);
}
}
};
loop { loop {
tokio::select! { tokio::select! {
// Read from PTY stdout/stderr // Read from PTY stdout/stderr
@ -209,6 +229,7 @@ impl SessionManager {
if !pty_session.is_alive() { if !pty_session.is_alive() {
debug!("Session {} PTY process exited", session_id); debug!("Session {} PTY process exited", session_id);
let _ = app_handle.emit(&format!("terminal-closed-{}", session_id), ()); let _ = app_handle.emit(&format!("terminal-closed-{}", session_id), ());
cleanup(&mut pty_session, &session_id, "process exited");
break; break;
} }
@ -225,6 +246,7 @@ impl SessionManager {
Err(e) => { Err(e) => {
error!("Failed to read from PTY for session {}: {}", session_id, e); error!("Failed to read from PTY for session {}: {}", session_id, e);
let _ = app_handle.emit(&format!("terminal-error-{}", session_id), e.to_string()); let _ = app_handle.emit(&format!("terminal-error-{}", session_id), e.to_string());
cleanup(&mut pty_session, &session_id, "read error");
break; break;
} }
} }
@ -235,6 +257,7 @@ impl SessionManager {
if let Err(e) = pty_session.write(&data) { if let Err(e) = pty_session.write(&data) {
error!("Failed to write to PTY for session {}: {}", session_id, e); error!("Failed to write to PTY for session {}: {}", session_id, e);
let _ = app_handle.emit(&format!("terminal-error-{}", session_id), e.to_string()); let _ = app_handle.emit(&format!("terminal-error-{}", session_id), e.to_string());
cleanup(&mut pty_session, &session_id, "write error");
break; break;
} }
} }
@ -244,12 +267,26 @@ impl SessionManager {
match cmd { match cmd {
ControlCommand::Resize { rows, cols } => { ControlCommand::Resize { rows, cols } => {
if let Err(e) = pty_session.resize(rows, cols) { if let Err(e) = pty_session.resize(rows, cols) {
warn!("Failed to resize PTY for session {}: {}", session_id, e); // A failed resize means the PTY is in an
// unrecoverable state (master fd closed, slave
// signal failed, etc.). Surface the error to
// the frontend and terminate the session
// rather than continuing with a stale layout.
error!(
"Failed to resize PTY for session {}: {}. Terminating session.",
session_id, e
);
let _ = app_handle.emit(
&format!("terminal-error-{}", session_id),
format!("PTY resize failed; session terminated: {e}"),
);
cleanup(&mut pty_session, &session_id, "resize error");
break;
} }
} }
ControlCommand::Terminate => { ControlCommand::Terminate => {
info!("Session {} received terminate command", session_id); info!("Session {} received terminate command", session_id);
let _ = pty_session.kill(); cleanup(&mut pty_session, &session_id, "terminate command");
break; break;
} }
} }

View File

@ -79,11 +79,49 @@ pub struct ApprovalResponse {
pub decision: String, // "deny", "allow_once", "allow_session" pub decision: String, // "deny", "allow_once", "allow_session"
} }
/// Application-wide shared state injected into every Tauri command via
/// `State<'_, AppState>`.
///
/// # Synchronization expectations
///
/// All fields except `app_data_dir` are wrapped in either a `std::sync::Mutex`
/// or a `tokio::sync::Mutex`. The choice is deliberate and **must** be
/// preserved by callers:
///
/// - **`std::sync::Mutex`** (e.g. `db`, `settings`, `integration_webviews`,
/// `watchers`): held for short, synchronous critical sections only. **Never
/// hold a `MutexGuard` across an `.await`** — `MutexGuard` is `!Send` and
/// the compiler will reject it. The standard pattern is to lock inside a
/// `{ }` block, take the data needed, drop the guard, then `.await`.
///
/// - **`tokio::sync::Mutex`** (e.g. `mcp_connections`, `pending_approvals`,
/// `clusters`, `port_forwards`, `refresh_registry`, `log_streams`): used
/// for state that must be held across an `.await` (network calls, channel
/// operations, etc.). These have an async `lock().await` API.
///
/// - **`Arc<crate::shell::SessionManager>`**: the manager itself owns its
/// internal locking via `RwLock`; callers do not lock the `Arc`.
///
/// - **`app_data_dir`**: immutable for the lifetime of the process; safe to
/// read without synchronization.
///
/// All fields are `pub` so command handlers in `commands/*.rs` can clone
/// individual `Arc`s into spawned tasks without taking the entire `AppState`.
/// Callers should treat the choice of mutex type as part of the API contract:
/// changing a `std::sync::Mutex` to a `tokio::sync::Mutex` (or vice-versa) is
/// a breaking change for every handler that touches the field.
pub struct AppState { pub struct AppState {
/// Encrypted SQLite (SQLCipher in release) connection. Short-lived locks
/// only; never held across `.await`.
pub db: Arc<Mutex<rusqlite::Connection>>, pub db: Arc<Mutex<rusqlite::Connection>>,
/// In-memory copy of `AppSettings`. Persisted to disk via the settings
/// commands; lock for read/write but never across `.await`.
pub settings: Arc<Mutex<AppSettings>>, pub settings: Arc<Mutex<AppSettings>>,
/// Resolved data directory (`~/.local/share/tftsr` on Linux, etc.).
/// Immutable for the process lifetime — no locking needed.
pub app_data_dir: PathBuf, pub app_data_dir: PathBuf,
/// Track open integration webview windows by service name -> window label /// Track open integration webview windows by service name -> window label.
/// Short-lived `std::sync::Mutex`.
pub integration_webviews: Arc<Mutex<HashMap<String, String>>>, pub integration_webviews: Arc<Mutex<HashMap<String, String>>>,
/// Live MCP server connections: server_id -> connection /// Live MCP server connections: server_id -> connection
pub mcp_connections: pub mcp_connections: