From 25ccff3a3782d40f567ed7933d866a5756cf4f21 Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Fri, 22 May 2026 22:06:32 +0700 Subject: [PATCH] Vain attempts to stop script --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app.rs | 218 +++++++++++++++++++++++++++++++++---- src/executor/mod.rs | 2 +- src/executor/structured.rs | 45 ++++++-- src/main.rs | 2 +- src/ui.rs | 77 ++++++++++--- 7 files changed, 296 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08fc970..431faef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "ostiary" -version = "1.0.10" +version = "1.0.11" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 8eb4c5f..58c7cc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.0.10" +version = "1.0.11" edition = "2024" [dependencies] diff --git a/src/app.rs b/src/app.rs index d7d78eb..de3ecac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,8 +54,11 @@ pub enum Popup { ExecutingBashTerminal { child: tokio::process::Child }, ExecutingStructured { reply_tx: tokio::sync::mpsc::UnboundedSender, - /// Fires SIGTERM to the script's process group on Esc. + script_pid: Option, kill_tx: Option>, + /// Background tokio tasks — aborted when popup closes to prevent + /// stale output from leaking into a subsequent popup. + task_handles: Vec>, log_buffer: Vec, current_command: Option, input_buffer: String, @@ -236,11 +239,24 @@ impl App { Ok(false) } Event::StructuredFinished(exit_code) => { - if let Some(Popup::ExecutingStructured { finished, .. }) = &mut self.popup { + if let Some(Popup::ExecutingStructured { + finished, + current_command, + .. + }) = &mut self.popup + { *finished = Some(exit_code); + // Clear any lingering non-interactive command (e.g. Progress + // left by a notify() call just before the script exited). + // Leaving it set would make has_command=true and block scroll. + if matches!( + current_command, + Some(executor::StructuredCommand::Progress { .. }) + | Some(executor::StructuredCommand::Message { .. }) + ) { + *current_command = None; + } } - // If popup was already closed (user pressed Esc to kill the - // script), we silently discard the exit event. Ok(false) } Event::UpdateAvailable(info) => { @@ -294,7 +310,9 @@ impl App { Popup::ExecutingStructured { reply_tx, + script_pid, kill_tx, + task_handles, input_buffer, current_command, menu_selected_index, @@ -303,7 +321,7 @@ impl App { log_scroll_pos, log_follow_bottom, log_buffer, - finished, + finished: _, } => { let is_menu = matches!( current_command, @@ -317,7 +335,16 @@ impl App { current_command, Some(executor::StructuredCommand::Form { .. }) ); - let has_command = current_command.is_some(); + let _has_command = current_command.is_some(); + // Only interactive commands block scroll/navigation. + // Progress and Message are display-only and must not block arrows. + let blocks_scroll = matches!( + current_command, + Some(executor::StructuredCommand::Input { .. }) + | Some(executor::StructuredCommand::Menu { .. }) + | Some(executor::StructuredCommand::Confirm { .. }) + | Some(executor::StructuredCommand::Form { .. }) + ); // Vim motions are disabled only when free text input is active. let is_text_input = matches!( current_command, @@ -402,11 +429,11 @@ impl App { } } // Scroll log one line when no interactive command pending - KeyCode::Up if !has_command => { + KeyCode::Up if !blocks_scroll => { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; } - KeyCode::Down if !has_command => { + KeyCode::Down if !blocks_scroll => { *log_scroll_pos = log_scroll_pos.saturating_add(1); *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); @@ -425,13 +452,13 @@ impl App { } // ── Text input ──────────────────────────────────── - // Excluded: vim motions (j/k/l/h) and form mode. + // Excluded: vim motions (j/k/l/h/d/u) and form mode. KeyCode::Char(c) if !is_menu && !is_confirm && !is_form && (is_text_input - || !matches!(c, 'j' | 'k' | 'l' | 'h')) => + || !matches!(c, 'j' | 'k' | 'l' | 'h' | 'd' | 'u')) => { input_buffer.push(c); } @@ -491,9 +518,10 @@ impl App { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); } else { - if let Some(kx) = kill_tx.take() { - let _ = kx.send(()); - } + debug_kill(format!("Esc pressed, pid={:?}", script_pid)); + kill_process(*script_pid); + if let Some(tx) = kill_tx.take() { let _ = tx.send(()); } + for h in task_handles.drain(..) { h.abort(); } self.popup = None; } } @@ -504,7 +532,7 @@ impl App { if *menu_selected_index > 0 { *menu_selected_index -= 1; } } else if is_form { if *form_cursor > 0 { *form_cursor -= 1; } - } else if !has_command { + } else if !blocks_scroll { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; } @@ -523,7 +551,7 @@ impl App { executor::StructuredCommand::Form { fields, .. } ) = current_command { fields.len() } else { 0 }; if *form_cursor + 1 < len { *form_cursor += 1; } - } else if !has_command { + } else if !blocks_scroll { *log_scroll_pos = log_scroll_pos.saturating_add(1); *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); @@ -563,18 +591,28 @@ impl App { } } KeyCode::Char('h') if !is_text_input => { - // Back / cancel — same logic as Esc. if is_confirm { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); } else { - if let Some(kx) = kill_tx.take() { - let _ = kx.send(()); - } + kill_process(*script_pid); + if let Some(tx) = kill_tx.take() { let _ = tx.send(()); } + for h in task_handles.drain(..) { h.abort(); } self.popup = None; } } + // ── d / u = PgDn / PgUp (vim half-page scroll) ── + KeyCode::Char('d') if !is_text_input && !is_form => { + *log_scroll_pos = log_scroll_pos.saturating_add(10); + *log_follow_bottom = + *log_scroll_pos + 1 >= log_buffer.len(); + } + KeyCode::Char('u') if !is_text_input && !is_form => { + *log_scroll_pos = log_scroll_pos.saturating_sub(10); + *log_follow_bottom = false; + } + _ => {} } return Ok(false); @@ -687,6 +725,45 @@ impl App { _ => {} } } + CrosstermEvent::Mouse(mouse) => { + use crossterm::event::MouseEventKind; + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(Popup::ExecutingStructured { + log_scroll_pos, + log_follow_bottom, + .. + }) = &mut self.popup + { + *log_scroll_pos = log_scroll_pos.saturating_sub(3); + *log_follow_bottom = false; + } else if self.popup.is_none() { + let len = self.current_items().len(); + if len > 0 { + self.selected_index = (self.selected_index + len - 1) % len; + } + } + } + MouseEventKind::ScrollDown => { + if let Some(Popup::ExecutingStructured { + log_scroll_pos, + log_follow_bottom, + log_buffer, + .. + }) = &mut self.popup + { + *log_scroll_pos = log_scroll_pos.saturating_add(3); + *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); + } else if self.popup.is_none() { + let len = self.current_items().len(); + if len > 0 { + self.selected_index = (self.selected_index + 1) % len; + } + } + } + _ => {} + } + } _ => {} } Ok(false) @@ -755,11 +832,11 @@ impl App { } async fn run_bash_structured(&mut self, script: &str) -> Result<()> { - let (output_rx, command_rx, finished_rx, reply_tx, kill_tx) = + let (output_rx, command_rx, finished_rx, reply_tx, kill_tx, pid) = executor::structured::spawn(script)?; let tx_output = self.event_tx.clone(); - tokio::spawn(async move { + let h_output = tokio::spawn(async move { let mut rx = output_rx; while let Some(line) = rx.recv().await { if tx_output.send(Event::StructuredOutput(line)).is_err() { @@ -769,7 +846,7 @@ impl App { }); let tx_cmd = self.event_tx.clone(); - tokio::spawn(async move { + let h_cmd = tokio::spawn(async move { let mut rx = command_rx; while let Some(cmd) = rx.recv().await { if tx_cmd.send(Event::StructuredCommand(cmd)).is_err() { @@ -779,14 +856,16 @@ impl App { }); let tx_finished = self.event_tx.clone(); - tokio::spawn(async move { + let h_finished = tokio::spawn(async move { let code = finished_rx.await.unwrap_or(1); let _ = tx_finished.send(Event::StructuredFinished(code)); }); self.popup = Some(Popup::ExecutingStructured { reply_tx, + script_pid: pid, kill_tx: Some(kill_tx), + task_handles: vec![h_output, h_cmd, h_finished], log_buffer: Vec::new(), current_command: None, input_buffer: String::new(), @@ -801,3 +880,96 @@ impl App { Ok(()) } } + +/// Find all descendant PIDs of `root_pid` by scanning /proc/*/status for PPid. +/// Works on any Linux including musl; no external tools needed. +#[cfg(target_os = "linux")] +fn find_descendants(root_pid: u32) -> Vec { + let mut result = Vec::new(); + let mut frontier = vec![root_pid]; + + // BFS: each round finds the next generation of children. + while !frontier.is_empty() { + let mut next = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let Ok(proc_pid) = entry.file_name().to_string_lossy().parse::() + else { continue }; + if result.contains(&proc_pid) || proc_pid == root_pid { + continue; + } + let path = format!("/proc/{}/status", proc_pid); + let Ok(status) = std::fs::read_to_string(&path) else { continue }; + let ppid: u32 = status + .lines() + .find(|l| l.starts_with("PPid:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if frontier.contains(&ppid) { + result.push(proc_pid); + next.push(proc_pid); + } + } + } + frontier = next; + } + result +} + +/// Kill a process and ALL its descendants (grep, php, mysql, …). +/// +/// Linux: scan /proc for every process whose PPid chain leads back to +/// `root_pid`, kill descendants first, then the root itself. +/// macOS: bash was spawned with process_group(0), so PGID == bash PID; +/// kill(-PGID, SIGKILL) terminates the whole group atomically. +fn kill_process(pid: Option) { + debug_kill(format!("kill_process pid={:?}", pid)); + let Some(pid) = pid else { return }; + + // ── Kill descendants BEFORE bash so the kernel can't reparent them ──────── + // + // On Linux: walk /proc/*/status PPid chain to collect all descendants. + #[cfg(target_os = "linux")] + { + let descendants = find_descendants(pid); + debug_kill(format!(" /proc descendants: {:?}", descendants)); + for &child in &descendants { + unsafe { libc::kill(child as libc::pid_t, libc::SIGKILL); } + } + } + // Use pgrep to find and log direct children by parent-PID, then kill each. + // pgrep -lP lists " " lines — gives us names for the log. + if let Ok(out) = std::process::Command::new("pgrep") + .args(["-lP", &pid.to_string()]) + .output() + { + let text = String::from_utf8_lossy(&out.stdout); + debug_kill(format!(" pgrep -lP {pid}: [{text}]")); + for line in text.lines() { + if let Some(child_pid) = line.split_whitespace().next() + .and_then(|s| s.parse::().ok()) + { + let r = unsafe { libc::kill(child_pid as libc::pid_t, libc::SIGKILL) }; + debug_kill(format!(" kill({child_pid})={r}")); + } + } + } + + // ── Kill bash itself (direct + whole process group) ─────────────────────── + #[cfg(unix)] + { + let r1 = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) }; + let r2 = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + debug_kill(format!(" kill(-{pid})={r1} kill({pid})={r2}")); + } +} + +fn debug_kill(msg: String) { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true).append(true).open("/tmp/ostiary-kill.log") + { + let _ = writeln!(f, "{}", msg); + } +} diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 01b162b..6a4890a 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -1,4 +1,4 @@ pub mod structured; -pub use structured::{StructuredCommand, MessageLevel, FormField, FormFieldType}; +pub use structured::{StructuredCommand, MessageLevel, FormFieldType}; // Здесь могут быть функции для download, http и т.д. diff --git a/src/executor/structured.rs b/src/executor/structured.rs index 822b8a9..63c54c0 100644 --- a/src/executor/structured.rs +++ b/src/executor/structured.rs @@ -28,7 +28,9 @@ pub enum StructuredCommand { text: String, }, Progress { - percent: u8, + /// Some(n) = deterministic bar (0-100). + /// None = indeterminate spinner (percent omitted from JSON). + percent: Option, message: Option, }, /// A list of checkboxes and radio buttons. @@ -86,6 +88,7 @@ pub fn spawn( tokio::sync::oneshot::Receiver, UnboundedSender, tokio::sync::oneshot::Sender<()>, // kill signal + Option, // bash PID for direct kill )> { let mut child = Command::new("bash") .arg("-c") @@ -93,10 +96,22 @@ pub fn spawn( .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) + // Create a new process group (PGID = bash PID) so that + // kill(-pid, SIGKILL) kills bash AND all its children + // (grep, php, mysql, etc.) without touching the parent ostiary. + .process_group(0) .spawn()?; // Capture the PID before moving child into tasks (needed for kill). let pid = child.id(); + dbg_kill(format!("spawn: bash pid={:?}", pid)); + // Log the actual process group bash landed in. + #[cfg(unix)] + if let Some(p) = pid { + let pgid = unsafe { libc::getpgid(p as libc::pid_t) }; + let my_pgid = unsafe { libc::getpgid(0) }; + dbg_kill(format!(" bash pgid={pgid} ostiary pgid={my_pgid}")); + } let stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); @@ -160,20 +175,33 @@ pub fn spawn( let _ = finished_tx.send(code); }); - // Kill-watcher: when kill_tx fires, send SIGTERM to the entire process - // group so bash AND all its children (mysqldump, php, etc.) are killed. + // Kill-watcher: when kill_tx fires, send SIGKILL to the entire process + // group so bash AND all its children are unconditionally terminated. tokio::spawn(async move { - if kill_rx.await.is_ok() { + let recv = kill_rx.await; + dbg_kill(format!("kill-watcher: recv={:?} pid={:?}", recv.is_ok(), pid)); + if recv.is_ok() { if let Some(pid) = pid { #[cfg(unix)] - unsafe { - libc::kill(-(pid as libc::pid_t), libc::SIGTERM); + { + let r1 = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) }; + let r2 = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + dbg_kill(format!(" kill-watcher kill(-{pid})={r1}, kill({pid})={r2}")); } } } }); - Ok((output_rx, cmd_rx, finished_rx, reply_tx, kill_tx)) + Ok((output_rx, cmd_rx, finished_rx, reply_tx, kill_tx, pid)) +} + +fn dbg_kill(msg: String) { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true).append(true).open("/tmp/ostiary-kill.log") + { + let _ = writeln!(f, "{}", msg); + } } fn parse_command(json_str: &str, cmd_tx: UnboundedSender) -> Result<()> { @@ -218,7 +246,8 @@ fn parse_command(json_str: &str, cmd_tx: UnboundedSender) -> StructuredCommand::Message { level, text } } "progress" => { - let percent = v["percent"].as_u64().unwrap_or(0) as u8; + // percent absent or null → None (indeterminate spinner) + let percent = v["percent"].as_u64().map(|n| n.min(100) as u8); let message = v["message"].as_str().map(String::from); StructuredCommand::Progress { percent, message } } diff --git a/src/main.rs b/src/main.rs index 4d04e3f..ea8188a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,7 +107,7 @@ async fn run_app( terminal.show_cursor()?; #[cfg(unix)] updater::exec_updated(&exe_path); - // fallback for non-unix or if exec failed + #[cfg(not(unix))] break; } diff --git a/src/ui.rs b/src/ui.rs index 1d941fb..f46c9c6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -127,7 +127,9 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { Popup::ExecutingStructured { reply_tx: _, + script_pid: _, kill_tx: _, + task_handles: _, log_buffer, current_command, input_buffer, @@ -184,13 +186,27 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { let at_bottom = pos >= max_pos; let at_top = pos == 0; + // Reusable scroll hint based on current position + let scroll_hint = if total <= visible { + // All content fits — nothing to scroll + String::new() + } else if at_top && at_bottom { + String::new() + } else if at_top { + format!(" │ PgDn/↓ вниз ({}/{})", end, total) + } else if at_bottom { + format!(" │ PgUp/↑ вверх ({}/{})", end, total) + } else { + format!(" │ PgUp ↑ PgDn ↓ ({}/{})", end, total) + }; + let (title, border_color): (String, Color) = match finished { Some(0) => ( - " ✓ Завершено — Esc закрыть │ PgUp/↑↓ скролл ".to_string(), + format!(" ✓ Завершено — Esc закрыть{} ", scroll_hint), Color::Green, ), Some(code) => ( - format!(" ✗ Ошибка (код {}) — Esc закрыть │ PgUp/↑↓ скролл ", code), + format!(" ✗ Ошибка (код {}) — Esc закрыть{} ", code, scroll_hint), Color::Red, ), None if at_top && at_bottom => ( @@ -486,22 +502,49 @@ fn render_command( } executor::StructuredCommand::Progress { percent, message } => { - let bar_width = area.width.saturating_sub(4) as usize; - let filled = (*percent as usize * bar_width) / 100; - let bar = format!( - "[{}{}] {}%", - "█".repeat(filled), - "░".repeat(bar_width.saturating_sub(filled)), - percent - ); let msg = message.as_deref().unwrap_or(""); - let paragraph = Paragraph::new(vec![Line::from(bar), Line::from(msg)]).block( - Block::default() - .borders(Borders::ALL) - .title(" Прогресс ") - .border_style(Style::default().fg(Color::Cyan)), - ); - f.render_widget(paragraph, area); + match percent { + Some(pct) => { + let bar_width = area.width.saturating_sub(4) as usize; + let filled = (*pct as usize * bar_width) / 100; + let bar = format!( + "[{}{}] {}%", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)), + pct + ); + let paragraph = Paragraph::new(vec![ + Line::from(bar), + Line::from(msg), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Прогресс ") + .border_style(Style::default().fg(Color::Cyan)), + ); + f.render_widget(paragraph, area); + } + None => { + // Indeterminate: spinning braille dots + const SPINNER: &[&str] = + &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let frame = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + / 120) as usize; + let spinner = SPINNER[frame % SPINNER.len()]; + let line = format!("{} {}", spinner, msg); + let paragraph = Paragraph::new(line).block( + Block::default() + .borders(Borders::ALL) + .title(" Прогресс ") + .border_style(Style::default().fg(Color::Cyan)), + ); + f.render_widget(paragraph, area); + } + } } executor::StructuredCommand::Form { prompt, fields } => {