feat: run shell commands asynchronously from command mode
This commit is contained in:
@@ -4,6 +4,77 @@ use std::{
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.open("");
|
||||
session.editor.set_project_root(fixture.0.clone());
|
||||
for ch in
|
||||
":!while [ ! -f release ]; do sleep 0.01; done; pwd; printf 'out'; printf 'err' >&2; exit 7"
|
||||
.chars()
|
||||
{
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Enter);
|
||||
let fixed = Instant::now();
|
||||
session.shell.as_mut().unwrap().2 = fixed;
|
||||
assert!(session.poll_shell_at(fixed + Duration::from_millis(100)));
|
||||
assert_eq!(session.editor.shell_label(), "whi... /");
|
||||
session.execute("!printf should-not-run > unexpected");
|
||||
assert_eq!(
|
||||
session.editor.message(),
|
||||
"A shell command is already running"
|
||||
);
|
||||
assert!(!fixture.0.join("unexpected").exists());
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('X'));
|
||||
assert_eq!(session.editor.lines(), ["Xsource"]);
|
||||
fixture.file("release", "");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor.command_output().is_some()
|
||||
});
|
||||
let output = session.editor.command_output().unwrap();
|
||||
assert!(output.title.contains("exit 7"));
|
||||
let text = output.content.lines.join("\n");
|
||||
assert!(text.contains(fixture.0.to_str().unwrap()));
|
||||
assert!(text.ends_with("out\n[stderr]\nerr"), "{text}");
|
||||
assert_eq!(session.editor.shell_label(), "");
|
||||
assert_eq!(session.editor.mode(), Mode::Insert);
|
||||
session.handle(Input::Char('Z'));
|
||||
assert_eq!(session.editor.lines(), ["Xsource"]);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('Y'));
|
||||
assert_eq!(session.editor.lines(), ["XYsource"]);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"source"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shell_commands_reject_empty_input_and_show_empty_success_without_editing() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.open("");
|
||||
session.execute(":!");
|
||||
assert_eq!(session.editor.message(), "Shell command is empty");
|
||||
assert!(session.shell.is_none());
|
||||
session.execute(":!true");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor.command_output().is_some()
|
||||
});
|
||||
let output = session.editor.command_output().unwrap();
|
||||
assert_eq!(output.content.lines, ["(no output)"]);
|
||||
assert!(output.title.contains("exit 0"));
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor.command_output().is_none());
|
||||
assert_eq!(session.editor.lines(), ["source"]);
|
||||
assert!(!session.editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
loop {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-shell-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
match fs::create_dir(&root) {
|
||||
Ok(()) => return Self(fs::canonicalize(root).unwrap()),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("Cannot create shell fixture: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn start(command: &str, root: &Path) -> Worker {
|
||||
Worker::start_with_shell(command.into(), root.into(), "/bin/sh".into()).unwrap()
|
||||
}
|
||||
|
||||
fn wait_for<T>(mut ready: impl FnMut() -> Option<T>) -> T {
|
||||
// File gates determine ordering; this deadline only bounds a stuck test.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(value) = ready() {
|
||||
return value;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "Shell fixture did not progress");
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn process_running(pid: &str) -> bool {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-o", "stat=", "-p", pid])
|
||||
.output()
|
||||
.unwrap();
|
||||
let state = String::from_utf8(output.stdout).unwrap();
|
||||
let state = state.trim();
|
||||
// An orphan may await the system reaper, but a zombie cannot keep running
|
||||
// or retain the inherited output descriptors.
|
||||
!state.is_empty() && !state.starts_with('Z')
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_is_pending_until_gate_and_preserves_cwd_streams_status_and_null_stdin() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"printf '%s\\n' \"$PWD\"; : > ready; \
|
||||
while [ ! -e release ]; do :; done; \
|
||||
if read value; then exit 99; fi; \
|
||||
printf 'out\\000bytes'; printf 'err\\000bytes' >&2; exit 7",
|
||||
&fixture.0,
|
||||
);
|
||||
wait_for(|| fixture.0.join("ready").exists().then_some(()));
|
||||
assert!(worker.poll().is_none());
|
||||
fs::write(fixture.0.join("release"), b"").unwrap();
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
let mut expected = fixture.0.as_os_str().as_encoded_bytes().to_vec();
|
||||
expected.extend_from_slice(b"\nout\0bytes");
|
||||
assert_eq!(output.stdout, expected);
|
||||
assert_eq!(output.stderr, b"err\0bytes");
|
||||
assert_eq!(output.code, Some(7));
|
||||
assert!(worker.poll().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_reports_spawn_failure() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start("exit 0", &fixture.0.join("missing"));
|
||||
let error = wait_for(|| worker.poll()).unwrap_err();
|
||||
assert!(error.starts_with("Cannot start shell command:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_shell_worker_reports_one_failure() {
|
||||
let (cancel, _cancelled) = mpsc::channel();
|
||||
let (send, results) = mpsc::channel();
|
||||
drop(send);
|
||||
let mut worker = Worker {
|
||||
cancel,
|
||||
results,
|
||||
thread: None,
|
||||
finished: false,
|
||||
};
|
||||
assert_eq!(
|
||||
worker.poll().unwrap().unwrap_err(),
|
||||
"Shell worker stopped unexpectedly"
|
||||
);
|
||||
assert!(worker.poll().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_drains_both_streams_concurrently_with_explicit_bounded_capture() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) & \
|
||||
(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) >&2 & wait",
|
||||
&fixture.0,
|
||||
);
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
assert_eq!(output.code, Some(0));
|
||||
for bytes in [output.stdout, output.stderr] {
|
||||
assert_eq!(bytes.len(), OUTPUT_LIMIT + TRUNCATION_NOTE.len());
|
||||
assert!(bytes[..OUTPUT_LIMIT].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(&bytes[OUTPUT_LIMIT..], TRUNCATION_NOTE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_shell_kills_and_reaps_group_with_pipe_inheriting_descendant() {
|
||||
let fixture = Fixture::new();
|
||||
let worker = start(
|
||||
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
|
||||
while [ ! -e release ]; do :; done' & \
|
||||
printf '%s' \"$$\" > leader; wait",
|
||||
&fixture.0,
|
||||
);
|
||||
wait_for(|| fixture.0.join("ready").exists().then_some(()));
|
||||
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
|
||||
let leader = wait_for(|| {
|
||||
fs::read_to_string(fixture.0.join("leader"))
|
||||
.ok()
|
||||
.filter(|pid| !pid.is_empty())
|
||||
});
|
||||
let (done, dropped) = mpsc::channel();
|
||||
let dropper = thread::spawn(move || {
|
||||
drop(worker);
|
||||
done.send(()).unwrap();
|
||||
});
|
||||
dropped.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
dropper.join().unwrap();
|
||||
wait_for(|| (!process_running(&descendant)).then_some(()));
|
||||
let leader_status = Command::new("/bin/ps")
|
||||
.args(["-o", "stat=", "-p", &leader])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
leader_status.stdout.is_empty(),
|
||||
"Shell leader was not reaped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_shell_does_not_leave_background_jobs_holding_output_open() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
|
||||
while [ ! -e release ]; do :; done' & \
|
||||
while [ ! -e ready ]; do :; done; printf done",
|
||||
&fixture.0,
|
||||
);
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
assert_eq!(output.code, Some(0));
|
||||
assert_eq!(output.stdout, b"done");
|
||||
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
|
||||
wait_for(|| (!process_running(&descendant)).then_some(()));
|
||||
}
|
||||
@@ -15354,3 +15354,93 @@ fn draw(
|
||||
frame, editor, workspace, workspace, top, left, syntax, document, panels,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_popup_routes_wheels_and_hides_document_cursor() {
|
||||
let mut session = EditorSession::open(None, vec![], None).unwrap();
|
||||
session.editor_mut().set_message("");
|
||||
let source = session.editor().lines().to_vec();
|
||||
let cursor = session.editor().cursor();
|
||||
session.editor_mut().show_command_output(
|
||||
"printf output".into(),
|
||||
Err((0..40)
|
||||
.map(|row| format!("OUTPUT {row} {}", "wide ".repeat(30)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")),
|
||||
);
|
||||
let area = Rect::new(3, 2, 80, 16);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(90, 22),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("printf output"));
|
||||
assert!(text.contains("OUTPUT 0"));
|
||||
assert!(text.contains("Enter/Esc close"));
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
let offsets = views.offsets.clone();
|
||||
assert!(views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, area.x, area.y)
|
||||
));
|
||||
assert_eq!(session.editor().command_output().unwrap().content.top, 3);
|
||||
assert!(views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollRight, area.x, area.y)
|
||||
));
|
||||
assert_eq!(session.editor().command_output().unwrap().content.left, 3);
|
||||
assert_eq!(views.offsets, offsets);
|
||||
assert_eq!(session.editor().lines(), source.as_slice());
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
session.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(session.editor().command_output().is_none());
|
||||
assert!(terminal.backend().cursor_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_panel_clips_sanitized_content_in_offset_and_tiny_areas() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.show_command_output(
|
||||
"shell title".into(),
|
||||
Err("SKIPPED\nabcdVISIBLE\u{1b} tail".into()),
|
||||
);
|
||||
editor.handle(Input::Down);
|
||||
for _ in 0..4 {
|
||||
editor.handle(Input::Right);
|
||||
}
|
||||
let diagnostic = &editor.command_output().unwrap().content;
|
||||
for (width, height) in [(50, 12), (3, 2), (1, 1), (0, 1), (1, 0)] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(60, 18),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_diagnostic_panel(frame, diagnostic, area, "shell title", false);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("SKIPPED"));
|
||||
assert!(!text.contains('\u{1b}'));
|
||||
if width == 50 {
|
||||
assert!(text.contains("shell title"));
|
||||
assert!(text.contains("VISIBLE"));
|
||||
assert!(!text.contains("abcd"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user