diff --git a/.gitignore b/.gitignore index 1ac24bc..776b8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,12 @@ test_* !ghostscope-process/ebpf/build_sysmon_bpf.sh # Keep installer script tracked !scripts/install.sh +# Keep e2e wrapper script tracked +!scripts/e2e_runner/run_e2e_runner.sh +# Keep e2e runner service launcher tracked +!scripts/e2e_runner/start_e2e_runner_service.sh +# Keep skill installer tracked +!scripts/e2e_runner/install_codex_skill.sh # Keep other C files in test fixtures but exclude random C files in root /*.c CLA* @@ -61,3 +67,6 @@ gdb* .gdb* libtest* nginx* + +# Python cache +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5cfb390 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# GhostScope Agent Notes + +## Skill Routing +- Prefer skill `ghostscope-e2e-runner` for all e2e execution requests. +- Install shared project skill with `./scripts/e2e_runner/install_codex_skill.sh` and restart Codex. + +## Scope +- Keep CI workflows and developer-facing docs on normal project test commands. +- Treat `run_e2e_runner.sh` as an agent-oriented operational helper. + +## Verification +- After code changes, always run formatting and lint checks before handoff. +- Use the same commands as CI in `.github/workflows/ci.yml` whenever possible. +- Local formatting: `cargo fmt --all` (single run is enough). +- CI uses `cargo fmt --all -- --check` for verification only. +- Minimum local checks (aligned with CI): + - `cargo clippy --all-targets --all-features -- -D warnings` +- If full-workspace `clippy` is too slow or blocked, run `clippy` for affected crates and clearly report scope. diff --git a/docs/development.md b/docs/development.md index 41046ed..5cf29f9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -149,6 +149,29 @@ After rebuilding, a regular workspace build will pick up the new objects automat sudo cargo test ``` +### Agent E2E Runner (Codex) + +This runner path is for running e2e from an AI agent environment, where the agent may not be able to execute `sudo cargo test` directly. + +The service must be started by the developer manually with `sudo`: + +```bash +cd /mnt/500g/code/ghostscope +sudo env HOST=127.0.0.1 PORT=8788 DEFAULT_SUDO=1 DEFAULT_REPO_DIR=/mnt/500g/code/ghostscope ./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +Then run e2e through the agent wrapper: + +```bash +./scripts/e2e_runner/run_e2e_runner.sh +``` + +Optional variables: + +- `E2E_REPO_DIR=/path/to/repo` +- `E2E_TEST_CASE=` +- `E2E_SUDO=1|0` (default: `1`) + ### Testing DWARF Parsing with dwarf-tool GhostScope provides a standalone `dwarf-tool` for testing and debugging DWARF parsing: diff --git a/docs/zh/development.md b/docs/zh/development.md index 9a4d91d..d8041b0 100644 --- a/docs/zh/development.md +++ b/docs/zh/development.md @@ -145,7 +145,33 @@ docker build -t ghostscope-builder:ubuntu20.04 . ## 测试 ### 集成测试和 UT + +```bash sudo cargo test +``` + +### Agent E2E Runner(Codex) + +该流程用于在 AI agent 环境中执行 e2e,目的是规避 agent 无法直接执行 `sudo cargo test` 的限制。 + +`runner service` 需要开发者自行使用 `sudo` 启动: + +```bash +cd /mnt/500g/code/ghostscope +sudo env HOST=127.0.0.1 PORT=8788 DEFAULT_SUDO=1 DEFAULT_REPO_DIR=/mnt/500g/code/ghostscope ./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +启动后,通过 agent 包装脚本触发 e2e: + +```bash +./scripts/e2e_runner/run_e2e_runner.sh +``` + +可选变量: + +- `E2E_REPO_DIR=/path/to/repo` +- `E2E_TEST_CASE=` +- `E2E_SUDO=1|0`(默认:`1`) ### 使用 dwarf-tool 测试 DWARF 解析 diff --git a/ghostscope-ui/src/components/app.rs b/ghostscope-ui/src/components/app.rs index ea34cae..bac1398 100644 --- a/ghostscope-ui/src/components/app.rs +++ b/ghostscope-ui/src/components/app.rs @@ -124,6 +124,11 @@ impl App { let mut loading_ui_ticker = tokio::time::interval(tokio::time::Duration::from_secs(1)); loading_ui_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Periodic housekeeping ticker for lightweight timeout/cleanup checks. + // Use an interval instead of recreating sleep futures in each select iteration. + let mut housekeeping_ticker = tokio::time::interval(tokio::time::Duration::from_millis(50)); + housekeeping_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Initial render self.terminal.draw(|f| Self::draw_ui(f, &mut self.state))?; @@ -175,8 +180,8 @@ impl App { needs_render = true; } - // Check for jk escape sequence timeout periodically - _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + // Check for jk escape sequence timeout and periodic cleanup + _ = housekeeping_ticker.tick() => { // Check jk timeout if crate::components::command_panel::input_handler::InputHandler::check_jk_timeout(&mut self.state.command_panel) { needs_render = true; @@ -3258,6 +3263,17 @@ impl App { }; let _ = self.handle_action(action); } + RuntimeStatus::TraceBackpressure { + dropped_since_last, + dropped_total, + queue_capacity, + } => { + self.show_trace_backpressure_alert( + dropped_since_last, + dropped_total, + queue_capacity, + ); + } _ => { // Handle other runtime status messages (delegate to command panel or other components) // For now, pass them to command panel for display @@ -3313,6 +3329,28 @@ impl App { self.state.ebpf_panel.add_trace_event(trace_event); } + fn show_trace_backpressure_alert( + &mut self, + dropped_since_last: u64, + dropped_total: u64, + queue_capacity: usize, + ) { + let content = format!( + "⚠ Trace queue saturated: dropped {dropped_since_last} events in last 1s (total {dropped_total}, capacity {queue_capacity})" + ); + let styled_lines = + crate::components::command_panel::ResponseFormatter::style_generic_message_lines( + &content, + ); + crate::components::command_panel::ResponseFormatter::upsert_runtime_alert_with_style( + &mut self.state.command_panel, + content, + Some(styled_lines), + crate::action::ResponseType::Warning, + ); + self.state.command_renderer.mark_pending_updates(); + } + /// Format runtime status for display in command panel fn format_runtime_status_for_display( &mut self, diff --git a/ghostscope-ui/src/components/command_panel/optimized_renderer.rs b/ghostscope-ui/src/components/command_panel/optimized_renderer.rs index 492aeb2..e21b197 100644 --- a/ghostscope-ui/src/components/command_panel/optimized_renderer.rs +++ b/ghostscope-ui/src/components/command_panel/optimized_renderer.rs @@ -127,7 +127,7 @@ impl OptimizedRenderer { } } } - LineType::Response => { + LineType::Response | LineType::RuntimeAlert => { // Check if we have pre-styled content if let Some(ref styled_content) = static_line.styled_content { let wrapped_lines = self.wrap_styled_line(styled_content, width as usize); diff --git a/ghostscope-ui/src/components/command_panel/response_formatter.rs b/ghostscope-ui/src/components/command_panel/response_formatter.rs index affcc6c..e676f28 100644 --- a/ghostscope-ui/src/components/command_panel/response_formatter.rs +++ b/ghostscope-ui/src/components/command_panel/response_formatter.rs @@ -186,6 +186,50 @@ impl ResponseFormatter { Self::update_static_lines(state); } + /// Upsert a runtime alert line that is independent from command history. + /// This is used for periodic/system warnings (e.g., backpressure) and must + /// remain visible even when no command has been entered yet. + pub fn upsert_runtime_alert_with_style( + state: &mut CommandPanelState, + content: String, + styled_lines: Option>>, + response_type: ResponseType, + ) { + state + .static_lines + .retain(|line| line.line_type != LineType::RuntimeAlert); + + if let Some(styled) = styled_lines { + for styled_line in styled { + let plain: String = styled_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + state.static_lines.push(StaticTextLine { + content: plain, + line_type: LineType::RuntimeAlert, + history_index: None, + response_type: Some(response_type), + styled_content: Some(styled_line), + }); + } + } else { + for line in Self::split_response_lines(&content) { + state.static_lines.push(StaticTextLine { + content: line, + line_type: LineType::RuntimeAlert, + history_index: None, + response_type: Some(response_type), + styled_content: None, + }); + } + } + + state.styled_buffer = None; + state.styled_at_history_index = None; + } + /// Helper method to create a simple single-line styled response /// This reduces code duplication for common response patterns pub fn add_simple_styled_response( @@ -306,10 +350,10 @@ impl ResponseFormatter { /// Update the static lines display from command history pub fn update_static_lines(state: &mut CommandPanelState) { - // Keep welcome messages but remove command/response lines + // Keep welcome/runtime alert messages but remove command/response lines state .static_lines - .retain(|line| line.line_type == LineType::Welcome); + .retain(|line| matches!(line.line_type, LineType::Welcome | LineType::RuntimeAlert)); state.styled_buffer = None; state.styled_at_history_index = None; @@ -376,7 +420,7 @@ impl ResponseFormatter { ) -> Vec> { match line.line_type { LineType::Command => Self::format_command_line(&line.content, width), - LineType::Response => Self::format_response_line(line, width), + LineType::Response | LineType::RuntimeAlert => Self::format_response_line(line, width), LineType::Welcome => Self::format_response_line(line, width), // Format welcome messages like responses LineType::CurrentInput => { if is_current_input { @@ -1345,4 +1389,59 @@ impl ResponseFormatter { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_alert_visible_without_command_history() { + let mut state = CommandPanelState::new(); + let content = "⚠ Trace queue saturated: dropped 10 events in last 1s".to_string(); + let styled = ResponseFormatter::style_generic_message_lines(&content); + + ResponseFormatter::upsert_runtime_alert_with_style( + &mut state, + content.clone(), + Some(styled), + ResponseType::Warning, + ); + + assert!(state.command_history.is_empty()); + assert!(state.static_lines.iter().any(|line| { + line.line_type == LineType::RuntimeAlert + && line.content.contains("Trace queue saturated") + })); + } + + #[test] + fn runtime_alert_is_upserted_and_survives_history_refresh() { + let mut state = CommandPanelState::new(); + + ResponseFormatter::upsert_runtime_alert_with_style( + &mut state, + "⚠ old alert".to_string(), + None, + ResponseType::Warning, + ); + ResponseFormatter::upsert_runtime_alert_with_style( + &mut state, + "⚠ new alert".to_string(), + None, + ResponseType::Warning, + ); + + // Add one command and refresh static lines to simulate normal command flow. + state.add_command_entry("info trace"); + ResponseFormatter::update_static_lines(&mut state); + + let alert_lines: Vec<_> = state + .static_lines + .iter() + .filter(|line| line.line_type == LineType::RuntimeAlert) + .collect(); + assert_eq!(alert_lines.len(), 1); + assert!(alert_lines[0].content.contains("new alert")); + } +} + // Removed tests for dynamic help styling (now pre-styled upstream) diff --git a/ghostscope-ui/src/events.rs b/ghostscope-ui/src/events.rs index 3759899..5a54c1e 100644 --- a/ghostscope-ui/src/events.rs +++ b/ghostscope-ui/src/events.rs @@ -58,7 +58,7 @@ pub struct EventRegistry { pub command_sender: mpsc::UnboundedSender, // Runtime -> TUI communication - pub trace_receiver: mpsc::UnboundedReceiver, + pub trace_receiver: mpsc::Receiver, pub status_receiver: mpsc::UnboundedReceiver, } @@ -916,6 +916,12 @@ pub enum RuntimeStatus { SrcPathFailed { error: String, }, + /// Runtime->UI trace channel backpressure warning (events dropped) + TraceBackpressure { + dropped_since_last: u64, + dropped_total: u64, + queue_capacity: usize, + }, } /// Statistics for a loaded module @@ -998,8 +1004,13 @@ pub struct SectionInfo { impl EventRegistry { pub fn new() -> (Self, RuntimeChannels) { + Self::new_with_trace_capacity(DEFAULT_TRACE_CHANNEL_CAPACITY) + } + + pub fn new_with_trace_capacity(trace_capacity: usize) -> (Self, RuntimeChannels) { + let trace_capacity = trace_capacity.max(1); let (command_tx, command_rx) = mpsc::unbounded_channel(); - let (trace_tx, trace_rx) = mpsc::unbounded_channel::(); + let (trace_tx, trace_rx) = mpsc::channel::(trace_capacity); let (status_tx, status_rx) = mpsc::unbounded_channel(); let registry = EventRegistry { @@ -1012,18 +1023,23 @@ impl EventRegistry { command_receiver: command_rx, trace_sender: trace_tx.clone(), status_sender: status_tx.clone(), + trace_channel_capacity: trace_capacity, }; (registry, channels) } } +/// Default queue size for runtime->UI trace events. +pub const DEFAULT_TRACE_CHANNEL_CAPACITY: usize = 4096; + /// Channels used by the runtime to receive commands and send events #[derive(Debug)] pub struct RuntimeChannels { pub command_receiver: mpsc::UnboundedReceiver, - pub trace_sender: mpsc::UnboundedSender, + pub trace_sender: mpsc::Sender, pub status_sender: mpsc::UnboundedSender, + pub trace_channel_capacity: usize, } impl RuntimeChannels { @@ -1033,7 +1049,7 @@ impl RuntimeChannels { } /// Create a trace sender that can be shared with other tasks - pub fn create_trace_sender(&self) -> mpsc::UnboundedSender { + pub fn create_trace_sender(&self) -> mpsc::Sender { self.trace_sender.clone() } } @@ -1206,6 +1222,30 @@ impl RuntimeStatus { } } +#[cfg(test)] +mod tests { + use super::*; + + fn sample_event(trace_id: u64) -> ParsedTraceEvent { + ParsedTraceEvent { + timestamp: 0, + trace_id, + pid: 42, + tid: 42, + instructions: vec![], + } + } + + #[test] + fn event_registry_uses_bounded_trace_channel_capacity() { + let (_registry, channels) = EventRegistry::new_with_trace_capacity(1); + assert_eq!(channels.trace_channel_capacity, 1); + + channels.trace_sender.try_send(sample_event(1)).unwrap(); + assert!(channels.trace_sender.try_send(sample_event(2)).is_err()); + } +} + /// Source path information for display (shared between UI and runtime) #[derive(Debug, Clone)] pub struct SourcePathInfo { diff --git a/ghostscope-ui/src/model/panel_state.rs b/ghostscope-ui/src/model/panel_state.rs index 4cce75c..61eb883 100644 --- a/ghostscope-ui/src/model/panel_state.rs +++ b/ghostscope-ui/src/model/panel_state.rs @@ -544,6 +544,7 @@ pub struct StaticTextLine { pub enum LineType { Command, Response, + RuntimeAlert, CurrentInput, Welcome, } diff --git a/ghostscope-ui/tests/app_integration_test.rs b/ghostscope-ui/tests/app_integration_test.rs index d35198a..cc4ca90 100644 --- a/ghostscope-ui/tests/app_integration_test.rs +++ b/ghostscope-ui/tests/app_integration_test.rs @@ -13,7 +13,7 @@ pub struct TestableApp { /// Channels for mocking runtime communication _command_receiver: Arc>>, - _trace_sender: mpsc::UnboundedSender, + _trace_sender: mpsc::Sender, _status_sender: mpsc::UnboundedSender, /// Internal app state (simplified for testing) @@ -40,7 +40,7 @@ impl TestableApp { pub fn new(width: u16, height: u16) -> Self { // Create mock channels let (_command_sender, command_receiver) = mpsc::unbounded_channel(); - let (trace_sender, _trace_receiver) = mpsc::unbounded_channel(); + let (trace_sender, _trace_receiver) = mpsc::channel(64); let (status_sender, _status_receiver) = mpsc::unbounded_channel(); // Create test terminal diff --git a/ghostscope-ui/tests/integration_test.rs b/ghostscope-ui/tests/integration_test.rs index 5ba08de..129cbf2 100644 --- a/ghostscope-ui/tests/integration_test.rs +++ b/ghostscope-ui/tests/integration_test.rs @@ -9,8 +9,8 @@ use tokio::sync::mpsc; pub struct TuiTestHarness { /// Mock channels for runtime communication pub command_receiver: mpsc::UnboundedReceiver, - pub trace_sender: mpsc::UnboundedSender, - pub trace_receiver: mpsc::UnboundedReceiver, + pub trace_sender: mpsc::Sender, + pub trace_receiver: mpsc::Receiver, pub status_sender: mpsc::UnboundedSender, pub status_receiver: mpsc::UnboundedReceiver, @@ -23,7 +23,7 @@ impl TuiTestHarness { pub fn new(width: u16, height: u16) -> Self { // Create mock channels - keep both ends to prevent channel closed errors let (_command_sender, command_receiver) = mpsc::unbounded_channel(); - let (trace_sender, trace_receiver) = mpsc::unbounded_channel(); + let (trace_sender, trace_receiver) = mpsc::channel(64); let (status_sender, status_receiver) = mpsc::unbounded_channel(); // Create test terminal @@ -44,7 +44,7 @@ impl TuiTestHarness { pub fn create_event_registry(&mut self) -> EventRegistry { // Create new channels for the EventRegistry let (command_sender, command_receiver) = mpsc::unbounded_channel(); - let (trace_sender, trace_receiver) = mpsc::unbounded_channel(); + let (trace_sender, trace_receiver) = mpsc::channel(64); let (status_sender, status_receiver) = mpsc::unbounded_channel(); // Store the command receiver for later use @@ -56,7 +56,7 @@ impl TuiTestHarness { self.status_sender = status_sender; // Also create separate channels for the EventRegistry - let (_trace_sender_for_registry, trace_receiver_for_registry) = mpsc::unbounded_channel(); + let (_trace_sender_for_registry, trace_receiver_for_registry) = mpsc::channel(64); let (_status_sender_for_registry, status_receiver_for_registry) = mpsc::unbounded_channel(); // Keep the receivers alive @@ -100,7 +100,9 @@ impl TuiTestHarness { /// Send a mock trace event pub fn send_trace_event(&mut self, event: ParsedTraceEvent) -> Result<()> { - self.trace_sender.send(event)?; + self.trace_sender + .try_send(event) + .map_err(|e| anyhow::anyhow!("Failed to enqueue trace event: {e}"))?; Ok(()) } diff --git a/ghostscope/src/runtime/coordinator.rs b/ghostscope/src/runtime/coordinator.rs index 2b155f1..5d425f4 100644 --- a/ghostscope/src/runtime/coordinator.rs +++ b/ghostscope/src/runtime/coordinator.rs @@ -2,8 +2,54 @@ use crate::config::{MergedConfig, ParsedArgs}; use crate::core::GhostSession; use crate::runtime::{dwarf_loader, info_handlers, source_handlers, trace_handlers}; use anyhow::Result; +use ghostscope_protocol::ParsedTraceEvent; use ghostscope_ui::{EventRegistry, RuntimeChannels, RuntimeCommand, RuntimeStatus}; -use tracing::{error, info}; +use tokio::sync::mpsc::error::TrySendError; +use tracing::{error, info, warn}; + +#[derive(Debug, Default)] +struct TraceBackpressureState { + dropped_since_last_report: u64, + dropped_total: u64, +} + +impl TraceBackpressureState { + fn record_drop(&mut self) { + self.dropped_since_last_report += 1; + self.dropped_total += 1; + } + + fn take_report(&mut self) -> Option<(u64, u64)> { + if self.dropped_since_last_report == 0 { + return None; + } + + let dropped_since_last = self.dropped_since_last_report; + self.dropped_since_last_report = 0; + Some((dropped_since_last, self.dropped_total)) + } +} + +enum TraceForwardResult { + Delivered, + Dropped, + ChannelClosed, +} + +fn forward_trace_event( + trace_sender: &tokio::sync::mpsc::Sender, + event_data: ParsedTraceEvent, + backpressure_state: &mut TraceBackpressureState, +) -> TraceForwardResult { + match trace_sender.try_send(event_data) { + Ok(()) => TraceForwardResult::Delivered, + Err(TrySendError::Full(_)) => { + backpressure_state.record_drop(); + TraceForwardResult::Dropped + } + Err(TrySendError::Closed(_)) => TraceForwardResult::ChannelClosed, + } +} /// Run GhostScope in TUI mode with merged configuration pub async fn run_tui_coordinator_with_config(config: MergedConfig) -> Result<()> { @@ -134,6 +180,10 @@ async fn run_runtime_coordinator( // Create trace sender for event polling task let trace_sender = runtime_channels.create_trace_sender(); + let trace_channel_capacity = runtime_channels.trace_channel_capacity; + let mut backpressure_state = TraceBackpressureState::default(); + let mut backpressure_report_ticker = tokio::time::interval(tokio::time::Duration::from_secs(1)); + backpressure_report_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { @@ -154,7 +204,22 @@ async fn run_runtime_coordinator( tracing::debug!("Forwarding {} trace events to UI", events.len()); } for event_data in events { - let _ = trace_sender.send(event_data); + match forward_trace_event( + &trace_sender, + event_data, + &mut backpressure_state, + ) { + TraceForwardResult::Delivered => {} + TraceForwardResult::Dropped => { + // `forward_trace_event` already increments backpressure counters on drop. + // We intentionally report drops in the 1s ticker branch below to avoid + // per-event status/log spam under sustained overload. + } + TraceForwardResult::ChannelClosed => { + warn!("Trace channel closed while forwarding events; stopping runtime coordinator"); + return Ok(()); + } + } } } } @@ -166,7 +231,11 @@ async fn run_runtime_coordinator( } // Handle runtime commands - Some(command) = runtime_channels.command_receiver.recv() => { + command = runtime_channels.command_receiver.recv() => { + let Some(command) = command else { + info!("Runtime command channel closed; stopping runtime coordinator"); + break; + }; match command { RuntimeCommand::ExecuteScript { command: script, selected_index } => { handle_execute_script(&mut session, &mut runtime_channels, script, selected_index, &compile_options).await; @@ -347,6 +416,22 @@ async fn run_runtime_coordinator( } } } + + _ = backpressure_report_ticker.tick() => { + if let Some((dropped_since_last, dropped_total)) = backpressure_state.take_report() { + warn!( + "Trace channel backpressure detected: dropped {} events in last interval (total {}, capacity {})", + dropped_since_last, + dropped_total, + trace_channel_capacity + ); + let _ = runtime_channels.status_sender.send(RuntimeStatus::TraceBackpressure { + dropped_since_last, + dropped_total, + queue_capacity: trace_channel_capacity, + }); + } + } } } @@ -544,3 +629,66 @@ async fn handle_load_traces( // Don't send TracesLoaded here - let the UI track individual completions // and send the final summary when all are done } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_event(trace_id: u64) -> ParsedTraceEvent { + ParsedTraceEvent { + timestamp: 0, + trace_id, + pid: 1000, + tid: 1000, + instructions: vec![], + } + } + + #[tokio::test] + async fn forward_trace_event_records_drop_when_channel_is_full() { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let mut state = TraceBackpressureState::default(); + + let first = forward_trace_event(&tx, sample_event(1), &mut state); + assert!(matches!(first, TraceForwardResult::Delivered)); + + let second = forward_trace_event(&tx, sample_event(2), &mut state); + assert!(matches!(second, TraceForwardResult::Dropped)); + + let report = state.take_report(); + assert_eq!(report, Some((1, 1))); + } + + #[tokio::test] + async fn forward_trace_event_detects_closed_channel() { + let (tx, rx) = tokio::sync::mpsc::channel(1); + let mut state = TraceBackpressureState::default(); + drop(rx); + + let result = forward_trace_event(&tx, sample_event(1), &mut state); + assert!(matches!(result, TraceForwardResult::ChannelClosed)); + assert_eq!(state.take_report(), None); + } + + #[tokio::test] + async fn runtime_coordinator_exits_when_command_channel_closed() { + let (event_registry, runtime_channels) = EventRegistry::new(); + drop(event_registry); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(200), + run_runtime_coordinator( + runtime_channels, + None, + ghostscope_compiler::CompileOptions::default(), + ), + ) + .await; + + assert!( + result.is_ok(), + "runtime coordinator should exit promptly when command channel is closed" + ); + assert!(result.unwrap().is_ok()); + } +} diff --git a/ghostscope/tests/complex_types_execution.rs b/ghostscope/tests/complex_types_execution.rs index 65112a3..a594cbc 100644 --- a/ghostscope/tests/complex_types_execution.rs +++ b/ghostscope/tests/complex_types_execution.rs @@ -1219,9 +1219,7 @@ async fn test_bitfields_correctness() -> anyhow::Result<()> { // Use source-line attach where 'a' and 'i' are in scope let script_fn = r#" trace complex_types_program.c:25 { - print "I={}", i; - print a.active; - print a.flags; + print "I={} ACTIVE={} FLAGS={}", i, a.active, a.flags; } "#; @@ -1235,52 +1233,21 @@ trace complex_types_program.c:25 { "ghostscope should run successfully. stderr={stderr} stdout={stdout}" ); - // Parse values from output - // Expect lines like: - // : I=1234 - // : c.active = 0/1 (or a.active = ... depending on var name) - // : c.flags = 0..7 + // Parse values from a single formatted line to avoid cross-event line pairing. + // Expected line shape: + // : I=1234 ACTIVE=0 FLAGS=4 use regex::Regex; - let re_i = Regex::new(r"I=([0-9]+)").unwrap(); - let re_active = Regex::new(r"(?i)(?:\b|\.)active\s*=\s*([0-9]+)").unwrap(); - let re_flags = Regex::new(r"(?i)(?:\b|\.)flags\s*=\s*([0-9]+)").unwrap(); - - let mut found_i: Option = None; - let mut found_active: Option = None; - let mut found_flags: Option = None; - - for line in stdout.lines() { - if found_i.is_none() { - if let Some(caps) = re_i.captures(line) { - if let Ok(val) = caps[1].parse::() { - found_i = Some(val); - } - } - } - if found_active.is_none() { - if let Some(caps) = re_active.captures(line) { - if let Ok(val) = caps[1].parse::() { - found_active = Some(val); - } - } - } - if found_flags.is_none() { - if let Some(caps) = re_flags.captures(line) { - if let Ok(val) = caps[1].parse::() { - found_flags = Some(val); - } - } - } - if found_i.is_some() && found_active.is_some() && found_flags.is_some() { - break; - } - } - - let i_val = found_i.ok_or_else(|| anyhow::anyhow!("Missing I=... line. STDOUT: {stdout}"))?; - let active_val = - found_active.ok_or_else(|| anyhow::anyhow!("Missing active line. STDOUT: {stdout}"))?; - let flags_val = - found_flags.ok_or_else(|| anyhow::anyhow!("Missing flags line. STDOUT: {stdout}"))?; + let re_triplet = Regex::new(r"I=([0-9]+)\s+ACTIVE=([0-9]+)\s+FLAGS=([0-9]+)").unwrap(); + let (i_val, active_val, flags_val) = stdout + .lines() + .find_map(|line| { + let caps = re_triplet.captures(line)?; + let i = caps.get(1)?.as_str().parse::().ok()?; + let active = caps.get(2)?.as_str().parse::().ok()?; + let flags = caps.get(3)?.as_str().parse::().ok()?; + Some((i, active, flags)) + }) + .ok_or_else(|| anyhow::anyhow!("Missing I/ACTIVE/FLAGS triplet. STDOUT: {stdout}"))?; assert!( active_val <= 1, diff --git a/scripts/e2e_runner/e2e_runner_service.md b/scripts/e2e_runner/e2e_runner_service.md new file mode 100644 index 0000000..4a5f85e --- /dev/null +++ b/scripts/e2e_runner/e2e_runner_service.md @@ -0,0 +1,56 @@ +# e2e runner service (agent-oriented) + +## Shared Skill Install + +Install project skill for Codex: + +```bash +./scripts/e2e_runner/install_codex_skill.sh +``` + +Then restart Codex. + +## Start service + +```bash +./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +Common env vars: + +- `HOST` / `PORT` (default: `127.0.0.1:8788`) +- `DEFAULT_REPO_DIR` (or legacy `REPO_DIR`) +- `LLVM_PREFIX` (default: `/usr/lib/llvm-18`) +- `DEFAULT_SUDO=1|0` +- `E2E_SERVICE_TOKEN=` (optional auth for POST) + +## Submit run + +`POST /runs` supports: + +- `sudo` (`true|false`, optional) +- `repo` (optional absolute path to repo root; must contain `Cargo.toml`) +- `test_case` (optional cargo test filter) + +Example: + +```bash +curl -sS -X POST http://127.0.0.1:8788/runs \ + -H 'Content-Type: application/json' \ + -d '{ + "sudo": true, + "repo": "/mnt/500g/code/ghostscope", + "test_case": "integration::basic_flow" + }' +``` + +## Agent-side trigger + +```bash +./scripts/e2e_runner/run_e2e_runner.sh +``` + +Optional env vars for agent trigger: + +- `E2E_REPO_DIR=/path/to/repo` +- `E2E_TEST_CASE=` diff --git a/scripts/e2e_runner/e2e_runner_service.py b/scripts/e2e_runner/e2e_runner_service.py new file mode 100755 index 0000000..f102483 --- /dev/null +++ b/scripts/e2e_runner/e2e_runner_service.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Simple HTTP service to trigger GhostScope e2e runs. + +Endpoints: +- GET /health +- GET /runs +- GET /runs/ +- GET /runs//log?tail=200 +- POST /runs + +POST /runs body (JSON, optional): +{ + "sudo": true, + "repo": "/mnt/500g/code/ghostscope", + "test_case": "my_case_name" +} + +If E2E_SERVICE_TOKEN is set, POST endpoints require header: +X-Auth-Token: +""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import shlex +import shutil +import signal +import subprocess +import threading +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qs, urlparse + + +DEFAULT_REPO = "/mnt/500g/code/ghostscope" +MAX_TEST_CASE_LEN = 256 + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class StepResult: + name: str + command: List[str] + started_at: Optional[str] = None + finished_at: Optional[str] = None + exit_code: Optional[int] = None + + +@dataclass +class Job: + id: str + requested_sudo: Optional[bool] + requested_repo: Optional[str] + repo: str + test_case: Optional[str] + status: str = "queued" + created_at: str = field(default_factory=now_iso) + started_at: Optional[str] = None + finished_at: Optional[str] = None + exit_code: Optional[int] = None + error: Optional[str] = None + steps: List[StepResult] = field(default_factory=list) + logs: List[str] = field(default_factory=list) + + +def validate_repo_path(repo: Path) -> Path: + resolved = repo.expanduser().resolve() + if not resolved.exists() or not (resolved / "Cargo.toml").exists(): + raise ValueError(f"Invalid repo path: {resolved}") + return resolved + + +def normalize_test_case(value: Optional[str]) -> Optional[str]: + if value is None: + return None + + candidate = value.strip() + if not candidate: + return None + + if len(candidate) > MAX_TEST_CASE_LEN: + raise ValueError(f"test_case too long (max {MAX_TEST_CASE_LEN})") + + if any(ch in candidate for ch in "\r\n\x00"): + raise ValueError("test_case cannot contain control characters") + + return candidate + + +class JobStore: + def __init__( + self, + default_repo: Path, + llvm_prefix: str, + default_sudo: bool, + cargo_home: Optional[str], + max_log_lines: int, + ) -> None: + self.default_repo = default_repo + self.llvm_prefix = llvm_prefix + self.default_sudo = default_sudo + self.cargo_home = cargo_home + self.max_log_lines = max_log_lines + + self._jobs: Dict[str, Job] = {} + self._order: List[str] = [] + self._lock = threading.Lock() + self._queue: "queue.Queue[Optional[str]]" = queue.Queue() + self._worker = threading.Thread(target=self._worker_loop, name="e2e-worker", daemon=True) + self._worker.start() + + def _resolve_repo(self, requested_repo: Optional[str]) -> Path: + if requested_repo is None: + return self.default_repo + + raw = requested_repo.strip() + if not raw: + return self.default_repo + + return validate_repo_path(Path(raw)) + + def create_job( + self, + requested_sudo: Optional[bool], + requested_repo: Optional[str], + requested_test_case: Optional[str], + ) -> Job: + repo = self._resolve_repo(requested_repo) + test_case = normalize_test_case(requested_test_case) + + job = Job( + id=uuid.uuid4().hex[:12], + requested_sudo=requested_sudo, + requested_repo=requested_repo, + repo=str(repo), + test_case=test_case, + ) + with self._lock: + self._jobs[job.id] = job + self._order.append(job.id) + self._queue.put(job.id) + return job + + def list_jobs(self, limit: int = 50) -> List[Dict[str, Any]]: + with self._lock: + job_ids = list(self._order[-limit:]) + jobs = [self._jobs[jid] for jid in job_ids] + return [self._job_summary(j) for j in reversed(jobs)] + + def get_job(self, job_id: str) -> Optional[Job]: + with self._lock: + return self._jobs.get(job_id) + + def stop(self) -> None: + self._queue.put(None) + self._worker.join(timeout=5) + + @property + def worker_alive(self) -> bool: + return self._worker.is_alive() + + def _append_log(self, job: Job, line: str) -> None: + stamped = f"[{now_iso()}] {line}" + with self._lock: + job.logs.append(stamped) + overflow = len(job.logs) - self.max_log_lines + if overflow > 0: + del job.logs[:overflow] + + def _set_status(self, job: Job, **updates: Any) -> None: + with self._lock: + for key, value in updates.items(): + setattr(job, key, value) + + def _job_summary(self, job: Job) -> Dict[str, Any]: + return { + "id": job.id, + "status": job.status, + "created_at": job.created_at, + "started_at": job.started_at, + "finished_at": job.finished_at, + "exit_code": job.exit_code, + "error": job.error, + "requested_sudo": job.requested_sudo, + "requested_repo": job.requested_repo, + "repo": job.repo, + "test_case": job.test_case, + "steps": len(job.steps), + "log_lines": len(job.logs), + } + + def _resolve_test_command(self, use_sudo: bool, test_case: Optional[str]) -> List[str]: + cargo_args = ["test", "--all-features"] + if test_case: + cargo_args.append(test_case) + + if os.geteuid() == 0 or not use_sudo: + return ["cargo", *cargo_args] + + cargo_path = shutil.which("cargo") or "cargo" + return ["sudo", "-E", cargo_path, *cargo_args] + + def _step_commands(self, requested_sudo: Optional[bool], test_case: Optional[str]) -> List[StepResult]: + use_sudo = self.default_sudo if requested_sudo is None else requested_sudo + + build_cmd = ["cargo", "test", "--no-run", "--all-features"] + if test_case: + build_cmd.append(test_case) + + return [ + StepResult(name="build_test_binaries", command=build_cmd), + StepResult(name="build_dwarf_tool", command=["cargo", "build", "-p", "dwarf-tool"]), + StepResult( + name="run_e2e_case" if test_case else "run_e2e", + command=self._resolve_test_command(use_sudo, test_case), + ), + ] + + def _run_step(self, job: Job, step: StepResult) -> int: + step.started_at = now_iso() + self._append_log(job, f"cwd={job.repo}") + self._append_log(job, f"$ {' '.join(shlex.quote(part) for part in step.command)}") + + env = os.environ.copy() + env["LLVM_SYS_181_PREFIX"] = self.llvm_prefix + if self.cargo_home: + env["CARGO_HOME"] = self.cargo_home + + process = subprocess.Popen( + step.command, + cwd=job.repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + assert process.stdout is not None + for line in process.stdout: + self._append_log(job, line.rstrip("\n")) + + process.wait() + step.exit_code = process.returncode + step.finished_at = now_iso() + self._append_log(job, f"Step '{step.name}' exited with code {process.returncode}") + return process.returncode + + def _worker_loop(self) -> None: + while True: + job_id = self._queue.get() + if job_id is None: + return + + job = self.get_job(job_id) + if job is None: + continue + + self._set_status(job, status="running", started_at=now_iso()) + self._append_log( + job, + ( + "starting job " + f"id={job.id} repo={job.repo} test_case={job.test_case or ''} " + f"requested_sudo={job.requested_sudo}" + ), + ) + + steps = self._step_commands(job.requested_sudo, job.test_case) + with self._lock: + job.steps = steps + + failed_code: Optional[int] = None + try: + for step in steps: + rc = self._run_step(job, step) + if rc != 0: + failed_code = rc + break + except Exception as exc: # noqa: BLE001 + self._append_log(job, f"Unhandled exception: {exc}") + self._set_status( + job, + status="failed", + exit_code=1, + error=str(exc), + finished_at=now_iso(), + ) + continue + + if failed_code is None: + self._set_status(job, status="succeeded", exit_code=0, finished_at=now_iso()) + else: + self._set_status(job, status="failed", exit_code=failed_code, finished_at=now_iso()) + + +class Handler(BaseHTTPRequestHandler): + store: JobStore + auth_token: str + + server_version = "e2e-runner/0.2" + + def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 + return + + def _write_json(self, status: int, payload: Dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _parse_json_body(self) -> Dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + if length == 0: + return {} + raw = self.rfile.read(length) + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + def _check_auth(self) -> bool: + if not self.auth_token: + return True + supplied = self.headers.get("X-Auth-Token", "") + return supplied == self.auth_token + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + path = parsed.path + + if path == "/health": + self._write_json( + HTTPStatus.OK, + { + "ok": True, + "worker_alive": self.store.worker_alive, + "default_repo": str(self.store.default_repo), + }, + ) + return + + if path == "/runs": + self._write_json(HTTPStatus.OK, {"runs": self.store.list_jobs()}) + return + + if path.startswith("/runs/"): + parts = path.strip("/").split("/") + if len(parts) < 2: + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "invalid path"}) + return + + job_id = parts[1] + job = self.store.get_job(job_id) + if job is None: + self._write_json(HTTPStatus.NOT_FOUND, {"error": "job not found"}) + return + + if len(parts) == 2: + data = asdict(job) + data["summary"] = self.store._job_summary(job) + data["log_tail"] = job.logs[-200:] + self._write_json(HTTPStatus.OK, data) + return + + if len(parts) == 3 and parts[2] == "log": + qs = parse_qs(parsed.query) + tail = 200 + if "tail" in qs: + try: + tail = max(1, min(5000, int(qs["tail"][0]))) + except ValueError: + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "tail must be an integer"}) + return + self._write_json( + HTTPStatus.OK, + { + "id": job.id, + "status": job.status, + "exit_code": job.exit_code, + "tail": tail, + "lines": job.logs[-tail:], + }, + ) + return + + self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + if not self._check_auth(): + self._write_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) + return + + parsed = urlparse(self.path) + path = parsed.path + + if path != "/runs": + self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + + try: + body = self._parse_json_body() + except json.JSONDecodeError: + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "invalid json body"}) + return + + requested_sudo = body.get("sudo") + if requested_sudo is not None and not isinstance(requested_sudo, bool): + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "sudo must be true/false"}) + return + + requested_repo = body.get("repo", body.get("repo_dir")) + if requested_repo is not None and not isinstance(requested_repo, str): + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "repo must be a string"}) + return + + requested_test_case = body.get("test_case") + if requested_test_case is not None and not isinstance(requested_test_case, str): + self._write_json(HTTPStatus.BAD_REQUEST, {"error": "test_case must be a string"}) + return + + try: + job = self.store.create_job( + requested_sudo=requested_sudo, + requested_repo=requested_repo, + requested_test_case=requested_test_case, + ) + except ValueError as exc: + self._write_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + self._write_json( + HTTPStatus.ACCEPTED, + { + "id": job.id, + "status": job.status, + "requested_sudo": job.requested_sudo, + "requested_repo": job.requested_repo, + "repo": job.repo, + "test_case": job.test_case, + }, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="GhostScope e2e runner service") + parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8788, help="Bind port (default: 8788)") + parser.add_argument( + "--repo", + default=DEFAULT_REPO, + help="Default repository path if request does not provide repo", + ) + parser.add_argument( + "--llvm-prefix", + default="/usr/lib/llvm-18", + help="LLVM_SYS_181_PREFIX value", + ) + parser.add_argument( + "--default-sudo", + action="store_true", + help="Use sudo for final e2e step by default", + ) + parser.add_argument( + "--cargo-home", + default=os.environ.get("CARGO_HOME"), + help="Optional CARGO_HOME for cargo cache", + ) + parser.add_argument( + "--max-log-lines", + type=int, + default=20000, + help="Maximum stored log lines per job", + ) + parser.add_argument( + "--token", + default=os.environ.get("E2E_SERVICE_TOKEN", ""), + help="Optional auth token for POST requests", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + default_repo = validate_repo_path(Path(args.repo)) + + store = JobStore( + default_repo=default_repo, + llvm_prefix=args.llvm_prefix, + default_sudo=args.default_sudo, + cargo_home=args.cargo_home, + max_log_lines=args.max_log_lines, + ) + + Handler.store = store + Handler.auth_token = args.token + + server = ThreadingHTTPServer((args.host, args.port), Handler) + + shutdown_once = threading.Event() + + def request_shutdown(reason: str) -> None: + if shutdown_once.is_set(): + return + shutdown_once.set() + print(f"Shutting down ({reason})...") + threading.Thread(target=server.shutdown, daemon=True).start() + + def shutdown_handler(signum: int, _frame: Any) -> None: + request_shutdown(f"signal {signum}") + + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + print(f"e2e-runner-service listening on http://{args.host}:{args.port}") + print(f"default_repo={default_repo}") + print(f"default_sudo={args.default_sudo}") + print(f"token_required={'yes' if args.token else 'no'}") + + try: + server.serve_forever() + except KeyboardInterrupt: + request_shutdown("keyboard interrupt") + finally: + store.stop() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/scripts/e2e_runner/install_codex_skill.sh b/scripts/e2e_runner/install_codex_skill.sh new file mode 100755 index 0000000..3510ba2 --- /dev/null +++ b/scripts/e2e_runner/install_codex_skill.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="link" +FORCE="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --copy) + MODE="copy" + shift + ;; + --link) + MODE="link" + shift + ;; + --force) + FORCE="1" + shift + ;; + -h|--help) + cat <<'EOF' +Install GhostScope shared Codex skill. + +Usage: + ./scripts/e2e_runner/install_codex_skill.sh [--link|--copy] [--force] + +Options: + --link Install as symlink (default) + --copy Install as copied directory + --force Replace existing destination if present +EOF + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SKILL_NAME="ghostscope-e2e-runner" +SKILL_SRC="$REPO_ROOT/skills/$SKILL_NAME" + +if [[ ! -d "$SKILL_SRC" || ! -f "$SKILL_SRC/SKILL.md" ]]; then + echo "Skill source not found: $SKILL_SRC" >&2 + exit 1 +fi + +CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +DEST_ROOT="$CODEX_HOME/skills" +DEST="$DEST_ROOT/$SKILL_NAME" + +mkdir -p "$DEST_ROOT" + +if [[ -e "$DEST" || -L "$DEST" ]]; then + if [[ "$FORCE" != "1" ]]; then + echo "Destination already exists: $DEST" >&2 + echo "Re-run with --force to replace it." >&2 + exit 1 + fi + rm -rf "$DEST" +fi + +if [[ "$MODE" == "link" ]]; then + ln -s "$SKILL_SRC" "$DEST" +else + cp -a "$SKILL_SRC" "$DEST" +fi + +echo "Installed skill '$SKILL_NAME' to: $DEST" +echo "Restart Codex to pick up new skills." diff --git a/scripts/e2e_runner/run_e2e_runner.sh b/scripts/e2e_runner/run_e2e_runner.sh new file mode 100755 index 0000000..3010aea --- /dev/null +++ b/scripts/e2e_runner/run_e2e_runner.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GhostScope AI-agent e2e entrypoint. +# Priority: +# 1) e2e runner service (HTTP) +# 2) local CI-equivalent commands fallback + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +SERVICE_URL="${E2E_SERVICE_URL:-http://127.0.0.1:8788}" +USE_SERVICE="${E2E_USE_SERVICE:-1}" +SERVICE_TOKEN="${E2E_SERVICE_TOKEN:-}" +E2E_SUDO="${E2E_SUDO:-1}" +E2E_REPO_DIR="${E2E_REPO_DIR:-}" +E2E_TEST_CASE="${E2E_TEST_CASE:-}" +POLL_INTERVAL="${E2E_POLL_INTERVAL:-5}" +MAX_WAIT_SEC="${E2E_MAX_WAIT_SEC:-7200}" +LOG_TAIL="${E2E_LOG_TAIL:-160}" + +if [[ -z "${LLVM_SYS_181_PREFIX:-}" ]]; then + export LLVM_SYS_181_PREFIX="/usr/lib/llvm-18" +fi + +CARGO_BIN="" + +is_true() { + local v="${1:-}" + case "${v,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +json_bool() { + if is_true "$1"; then + printf 'true' + else + printf 'false' + fi +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 2 + fi +} + +json_get() { + local key="$1" + python3 -c ' +import json +import sys +key = sys.argv[1] +try: + data = json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +value = data.get(key) +print("" if value is None else value) +' "$key" +} + +print_service_log_tail() { + local job_id="$1" + local response + local auth_args=() + if [[ -n "$SERVICE_TOKEN" ]]; then + auth_args=(-H "X-Auth-Token: $SERVICE_TOKEN") + fi + if ! response="$(curl -fsS "${auth_args[@]}" "${SERVICE_URL}/runs/${job_id}/log?tail=${LOG_TAIL}")"; then + echo "[e2e] warning: failed to fetch log tail for ${job_id}" >&2 + return + fi + + echo "[e2e] ===== log tail (last ${LOG_TAIL}) for job ${job_id} =====" + python3 -c ' +import json +import sys +try: + data = json.load(sys.stdin) +except Exception: + print("") + raise SystemExit(0) +for line in data.get("lines", []): + print(line) +' <<<"$response" + echo "[e2e] ===== end log tail =====" +} + +run_local_ci() { + require_cmd cargo + CARGO_BIN="$(command -v cargo)" + + local repo_dir + if [[ -n "$E2E_REPO_DIR" ]]; then + repo_dir="$E2E_REPO_DIR" + else + repo_dir="$DEFAULT_REPO_DIR" + fi + + if [[ ! -d "$repo_dir" || ! -f "$repo_dir/Cargo.toml" ]]; then + echo "[e2e] invalid E2E_REPO_DIR (Cargo.toml not found): $repo_dir" >&2 + return 2 + fi + + local build_no_run_cmd=("$CARGO_BIN" test --no-run --all-features) + local run_test_cmd=("$CARGO_BIN" test --all-features) + if [[ -n "$E2E_TEST_CASE" ]]; then + build_no_run_cmd+=("$E2E_TEST_CASE") + run_test_cmd+=("$E2E_TEST_CASE") + fi + + echo "[e2e] service unavailable or disabled, running local CI-equivalent commands" + echo "[e2e] repo_dir=${repo_dir} test_case=${E2E_TEST_CASE:-}" + echo "[e2e] LLVM_SYS_181_PREFIX=${LLVM_SYS_181_PREFIX}" + + ( + cd "$repo_dir" + "${build_no_run_cmd[@]}" + "$CARGO_BIN" build -p dwarf-tool + + if is_true "$E2E_SUDO" && [[ "$(id -u)" -ne 0 ]]; then + echo "[e2e] running final step with sudo" + sudo -E "${run_test_cmd[@]}" + else + echo "[e2e] running final step without sudo" + "${run_test_cmd[@]}" + fi + ) +} + +run_service_ci() { + require_cmd curl + require_cmd python3 + + local auth_args=() + if [[ -n "$SERVICE_TOKEN" ]]; then + auth_args=(-H "X-Auth-Token: $SERVICE_TOKEN") + fi + + if ! curl -fsS "${SERVICE_URL}/health" >/dev/null; then + return 10 + fi + + local post_body + post_body="$( + E2E_SUDO_BOOL="$(json_bool "$E2E_SUDO")" \ + E2E_REPO_DIR="$E2E_REPO_DIR" \ + E2E_TEST_CASE="$E2E_TEST_CASE" \ + python3 -c ' +import json +import os + +payload = {"sudo": os.environ.get("E2E_SUDO_BOOL", "false") == "true"} +repo_dir = os.environ.get("E2E_REPO_DIR", "").strip() +test_case = os.environ.get("E2E_TEST_CASE", "").strip() + +if repo_dir: + payload["repo"] = repo_dir +if test_case: + payload["test_case"] = test_case + +print(json.dumps(payload)) +' + )" + + local submit_resp + submit_resp="$(curl -fsS -X POST "${auth_args[@]}" \ + -H 'Content-Type: application/json' \ + -d "$post_body" \ + "${SERVICE_URL}/runs")" + + local job_id submit_status submit_repo submit_case + job_id="$(json_get id <<<"$submit_resp")" + submit_status="$(json_get status <<<"$submit_resp")" + submit_repo="$(json_get repo <<<"$submit_resp")" + submit_case="$(json_get test_case <<<"$submit_resp")" + + if [[ -z "$job_id" ]]; then + echo "[e2e] failed to parse job id from service response: $submit_resp" >&2 + return 11 + fi + + echo "[e2e] submitted job_id=${job_id} status=${submit_status} sudo=$(json_bool "$E2E_SUDO") repo=${submit_repo:-} test_case=${submit_case:-}" + + local started_at now elapsed + started_at="$(date +%s)" + + while true; do + local resp st ec + resp="$(curl -fsS "${auth_args[@]}" "${SERVICE_URL}/runs/${job_id}")" + st="$(json_get status <<<"$resp")" + ec="$(json_get exit_code <<<"$resp")" + + echo "[e2e] job=${job_id} status=${st} exit_code=${ec}" + + if [[ "$st" == "succeeded" ]]; then + print_service_log_tail "$job_id" + return 0 + fi + + if [[ "$st" == "failed" ]]; then + print_service_log_tail "$job_id" + if [[ -n "$ec" && "$ec" != "None" ]]; then + return "$ec" + fi + return 1 + fi + + now="$(date +%s)" + elapsed="$(( now - started_at ))" + if (( elapsed > MAX_WAIT_SEC )); then + echo "[e2e] timeout waiting for job ${job_id} (> ${MAX_WAIT_SEC}s)" >&2 + print_service_log_tail "$job_id" + return 124 + fi + + sleep "$POLL_INTERVAL" + done +} + +main() { + echo "[e2e] entrypoint: scripts/e2e_runner/run_e2e_runner.sh" + echo "[e2e] service=${SERVICE_URL} use_service=${USE_SERVICE} e2e_sudo=${E2E_SUDO} repo=${E2E_REPO_DIR:-$DEFAULT_REPO_DIR} test_case=${E2E_TEST_CASE:-}" + + if is_true "$USE_SERVICE"; then + run_service_ci + local rc=$? + if [[ "$rc" -eq 0 ]]; then + echo "[e2e] completed via service" + return 0 + fi + + # Fallback only when service path itself is unavailable/broken. + # If service executed tests and returned test failure, propagate it. + if [[ "$rc" -eq 10 || "$rc" -eq 11 ]]; then + echo "[e2e] service path unavailable (rc=${rc}), fallback to local" >&2 + else + echo "[e2e] service executed run and failed (rc=${rc}), not falling back" >&2 + return "$rc" + fi + fi + + run_local_ci + echo "[e2e] completed via local fallback" +} + +main "$@" diff --git a/scripts/e2e_runner/start_e2e_runner_service.sh b/scripts/e2e_runner/start_e2e_runner_service.sh new file mode 100755 index 0000000..72b8ec9 --- /dev/null +++ b/scripts/e2e_runner/start_e2e_runner_service.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_REPO_DIR="${DEFAULT_REPO_DIR:-${REPO_DIR:-/mnt/500g/code/ghostscope}}" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8788}" +LLVM_PREFIX="${LLVM_PREFIX:-/usr/lib/llvm-18}" +CARGO_HOME_DIR="${CARGO_HOME_DIR:-}" +DEFAULT_SUDO="${DEFAULT_SUDO:-1}" +SERVICE_TOKEN="${E2E_SERVICE_TOKEN:-}" + +ARGS=( + --host "$HOST" + --port "$PORT" + --repo "$DEFAULT_REPO_DIR" + --llvm-prefix "$LLVM_PREFIX" +) + +if [[ "$DEFAULT_SUDO" == "1" ]]; then + ARGS+=(--default-sudo) +fi + +if [[ -n "$CARGO_HOME_DIR" ]]; then + ARGS+=(--cargo-home "$CARGO_HOME_DIR") +fi + +if [[ -n "$SERVICE_TOKEN" ]]; then + ARGS+=(--token "$SERVICE_TOKEN") +fi + +exec python3 "$SCRIPT_DIR/e2e_runner_service.py" "${ARGS[@]}" diff --git a/skills/ghostscope-e2e-runner/SKILL.md b/skills/ghostscope-e2e-runner/SKILL.md new file mode 100644 index 0000000..8720507 --- /dev/null +++ b/skills/ghostscope-e2e-runner/SKILL.md @@ -0,0 +1,75 @@ +--- +name: ghostscope-e2e-runner +description: Run GhostScope e2e tests through the project runner scripts and runner service. Use when the user asks to execute e2e tests, run a specific test case, run tests for a specific repo path, diagnose e2e failures, or handle sudo/permission issues around eBPF test execution. +--- + +# GhostScope E2E Runner + +## Overview + +Execute GhostScope e2e with the project-standard runner in `scripts/e2e_runner/`. +Prefer the HTTP runner service path, then use local fallback only when the service path is unavailable. + +## Core Commands + +Use repository root `/mnt/500g/code/ghostscope` unless the user gives another path. + +Start runner service (user can run with sudo when required): + +```bash +./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +Run one case through service: + +```bash +E2E_USE_SERVICE=1 \ +E2E_SERVICE_URL=http://127.0.0.1:8788 \ +E2E_SUDO=1 \ +E2E_REPO_DIR=/mnt/500g/code/ghostscope \ +E2E_TEST_CASE=test_rust_script_print_globals \ +./scripts/e2e_runner/run_e2e_runner.sh +``` + +Run full e2e set (no case filter): + +```bash +E2E_USE_SERVICE=1 E2E_SUDO=1 ./scripts/e2e_runner/run_e2e_runner.sh +``` + +Check service health: + +```bash +curl -sS http://127.0.0.1:8788/health +``` + +## Execution Flow + +1. Check `/health` before submitting runs when the user expects service mode. +2. Run `scripts/e2e_runner/run_e2e_runner.sh` with explicit env vars: +- `E2E_REPO_DIR` when repo is not default. +- `E2E_TEST_CASE` when user asks for a single case. +- `E2E_SUDO=1` for eBPF tests that require elevated privileges. +3. Wait for final status and report: +- job id +- status and exit code +- failing test name and first actionable error +4. Avoid silent fallback on test failures: +- treat failed service test run as real failure, not transport failure. + +## Failure Handling + +If output contains `GhostScope needs elevated privileges to load eBPF programs`: +- confirm service was started with sudo or environment has required capabilities. +- rerun with `E2E_SUDO=1`. + +If output shows `sudo: a password is required`: +- ask user to start service as root/sudo in their terminal. +- or ask user to provide a non-interactive sudo setup. + +If output contains invalid repo path or missing `Cargo.toml`: +- correct `E2E_REPO_DIR` or service `DEFAULT_REPO_DIR`. + +## References + +Use [quick-reference.md](references/quick-reference.md) for command templates and environment variables. diff --git a/skills/ghostscope-e2e-runner/agents/openai.yaml b/skills/ghostscope-e2e-runner/agents/openai.yaml new file mode 100644 index 0000000..1614e4e --- /dev/null +++ b/skills/ghostscope-e2e-runner/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "GhostScope E2E Runner" + short_description: "Run GhostScope e2e via runner service or fallback flow" + default_prompt: "Use this skill to run GhostScope e2e tests through scripts/e2e_runner and report results." diff --git a/skills/ghostscope-e2e-runner/references/quick-reference.md b/skills/ghostscope-e2e-runner/references/quick-reference.md new file mode 100644 index 0000000..ae608c3 --- /dev/null +++ b/skills/ghostscope-e2e-runner/references/quick-reference.md @@ -0,0 +1,52 @@ +# GhostScope E2E Runner Quick Reference + +## Service Startup + +Use project scripts: + +```bash +cd /mnt/500g/code/ghostscope +./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +Root startup example: + +```bash +cd /mnt/500g/code/ghostscope +sudo env HOST=127.0.0.1 PORT=8788 DEFAULT_SUDO=1 DEFAULT_REPO_DIR=/mnt/500g/code/ghostscope ./scripts/e2e_runner/start_e2e_runner_service.sh +``` + +## Trigger Runs + +Run one case: + +```bash +E2E_USE_SERVICE=1 \ +E2E_SERVICE_URL=http://127.0.0.1:8788 \ +E2E_SUDO=1 \ +E2E_REPO_DIR=/mnt/500g/code/ghostscope \ +E2E_TEST_CASE=test_rust_script_print_globals \ +./scripts/e2e_runner/run_e2e_runner.sh +``` + +Run all: + +```bash +E2E_USE_SERVICE=1 E2E_SUDO=1 ./scripts/e2e_runner/run_e2e_runner.sh +``` + +## API + +Health: + +```bash +curl -sS http://127.0.0.1:8788/health +``` + +Submit: + +```bash +curl -sS -X POST http://127.0.0.1:8788/runs \ + -H 'Content-Type: application/json' \ + -d '{"sudo": true, "repo": "/mnt/500g/code/ghostscope", "test_case": "test_rust_script_print_globals"}' +```