fix: retain alternate screen across executable restart

This commit is contained in:
2026-10-12 23:26:38 +02:00
parent d85998e1cb
commit c35abe0f03
6 changed files with 273 additions and 103 deletions
+121
View File
@@ -314,3 +314,124 @@ fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() {
assert_eq!(invocation.plugin, "example");
}
}
#[cfg(unix)]
#[test]
fn executable_restart_keeps_the_alternate_screen_and_restores_terminal_on_exit() {
for scenario in ["quit", "startup-error"] {
let fixture = Fixture::new();
let path = fixture.0.join("startup file.txt");
fs::write(&path, "SCREEN_HANDOFF_DOCUMENT\n").unwrap();
let output =
std::process::Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
.args([
"-rpty",
"-rtimeout",
"-rio/console",
"-e",
r#"
scenario, binary, path = ARGV
captured = +"".b
status = nil
PTY.spawn({"TERM" => "xterm-256color"}, binary, "--no-config", "--", path) do |output, input, pid|
phase = 0
begin
input.winsize = [24, 100]
Timeout.timeout(20) do
loop do
if IO.select([output], nil, nil, 0.05)
begin
chunk = output.read_nonblock(65536)
captured << chunk
STDOUT.write(chunk)
rescue IO::WaitReadable
rescue EOFError, Errno::EIO
end
end
unless status
waited = Process.waitpid2(pid, Process::WNOHANG)
status = waited[1] if waited
end
if status
# waitpid can win the race with the final output read.
loop do
begin
chunk = output.read_nonblock(65536)
captured << chunk
STDOUT.write(chunk)
rescue IO::WaitReadable, EOFError, Errno::EIO
break
end
end
break
end
# A fresh process re-enables input reporting before its first repaint.
# Command-overlay frames must not be mistaken for a relaunched editor.
sessions = captured.split("\e[?2004h", -1)
next unless sessions.length == phase + 2
frame = sessions.last
next unless frame.include?("\e[?2026l")
visible = frame.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "")
next unless visible.include?("SCREEN_HANDOFF_DOCUMENT")
raise "alternate screen re-entered during restart" unless captured.scan("\e[?1049h").length == 1
raise "normal screen exposed during restart" if captured.include?("\e[?1049l")
if scenario == "startup-error"
File.binwrite(path, "\xff".b)
input.write(":restart\r")
else
input.write([":restart\r", ":restart!\r", ":q\r"].fetch(phase))
end
input.flush
phase += 1
end
expected_phases = scenario == "quit" ? 3 : 1
raise "exited before requested restart/quit" unless phase == expected_phases
raise "unexpected exit status: #{status.inspect}" unless status.success? == (scenario == "quit")
# Inspect the master: macOS revokes slave descriptors when their session exits.
raise "terminal echo was not restored" unless input.echo?
settings = IO.popen(["stty", "-a"], in: input, &:read)
raise "terminal remained noncanonical: #{settings}" unless settings.match?(/(?:^|[;\s])icanon(?:[;\s]|$)/)
end
ensure
unless status
begin
Process.kill("KILL", pid)
rescue Errno::ESRCH
end
begin
Process.waitpid(pid)
rescue Errno::ECHILD
end
end
input.close
end
end
"#,
"--",
scenario,
env!("CARGO_BIN_EXE_vamp"),
])
.arg(&path)
.output()
.expect("Ruby with stdlib PTY and io/console is required (set VAMP_RUBY)");
let captured = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"{scenario}: {}\nterminal output: {captured:?}",
String::from_utf8_lossy(&output.stderr),
);
assert_eq!(
captured.matches("\x1b[?1049h").count(),
1,
"{scenario}: {captured:?}",
);
assert_eq!(
captured.matches("\x1b[?1049l").count(),
1,
"{scenario}: {captured:?}",
);
}
}