Implement retained virtual terminal presentation
This commit is contained in:
parent
aef24440a5
commit
f468592d95
12 changed files with 811 additions and 71 deletions
|
|
@ -20,6 +20,7 @@ path = "tests/recovery-process.rs"
|
|||
harness = false
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
getrandom.workspace = true
|
||||
indicatif.workspace = true
|
||||
|
|
@ -42,6 +43,7 @@ serde.workspace = true
|
|||
sha2.workspace = true
|
||||
time.workspace = true
|
||||
tokio.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
HgIjACsADwBpARYFdG11eC0yNTZjb2xvcnx0bXV4IHdpdGggMjU2IGNvbG9ycwAAAQAAAQAAAAEBAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABUAAAAAgAAAAYAAAA/////////////////////////////////////////////////////wABAAAAAAEAAAAEAAYACAAZAB4AJQApAC0A//84AEkASwBPAFYA//9YAGQA//9oAGsAcQB1AHkA//9/AIEAhgCLAP//lACZAJ4A//+jAKgArQD//7IAtAC5AP//wgDHAM0A0wD//9YA///YAP///////9wA///gAP///////+IA///nAP//////////6wDvAPUA+QD9AAEBBwENARMBGQEfASQB//8pAf//LQEyATcBOwFCAf//SQFNAVUB//////////////////////////////////9dAf//YAFpAXIBewGEAY0BlgGfAagBsQH/////////////////////ugH/////ywHOAdkB3AHeAeEBOgL//z0CPwL//////////////////////////0QC//+FAv///////////////4kC/////////////////////5AC/////////////////////////////////////////////////////////////////////5UC/////5wC//////////+jAqoCsQL/////uAL//78C////////xgL/////////////zQLTAtkC4ALnAu4C9QL9AgUDDQMVAx0DJQMtAzUDPANDA0oDUQNZA2EDaQNxA3kDgQOJA5EDmAOfA6YDrQO1A70DxQPNA9UD3QPlA+0D9AP7AwIECQQRBBkEIQQpBDEEOQRBBEkEUARXBF4E/////////////////////////////////////////////////////////////2MEbgRzBHsEfwT//////////////////////////////////4gE////////////////////////jQT///////////////////////////////////////////////////////////////////////////////////////+TBP///////5cE1gQbW1oABwANABtbJWklcDElZDslcDIlZHIAG1sz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
|
||||
|
|
@ -9,6 +9,7 @@ use std::os::unix::fs::{FileExt, PermissionsExt};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
|
||||
use lazaret_core::identity::{
|
||||
BundleDigest, ContainerId, HostPath, MountIdentity, RunscBuildId, RunscDigest,
|
||||
|
|
@ -25,8 +26,8 @@ use lazaret_linux::process::{
|
|||
};
|
||||
use lazaret_linux::user_namespace::UserNamespaceMap;
|
||||
use lazaret_runtime_supervisor::sandbox::{
|
||||
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
build_sandbox_bundle_with_workspace,
|
||||
HostTerminal, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
build_sandbox_bundle_with_workspace, stage_host_terminal,
|
||||
};
|
||||
use lazaret_runtime_supervisor::supervision::{
|
||||
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
|
||||
|
|
@ -50,6 +51,8 @@ use crate::terminal::RunscInteractiveSessionExecutor;
|
|||
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
|
||||
|
||||
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
||||
const CONTROLLED_TERM: &str = "tmux-256color";
|
||||
const CONTROLLED_TERMINFO: &str = include_str!("../assets/tmux-256color.base64");
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ColdStartError(String);
|
||||
|
|
@ -199,13 +202,21 @@ impl<'a> ColdStart<'a> {
|
|||
.map_err(|error| failure(format!("network policy: {error}")))?;
|
||||
runtime_files::replace_protected_runtime_files(&state_anchor_layout, &network_policy)
|
||||
.map_err(|error| failure(format!("protected runtime files: {error}")))?;
|
||||
let terminal_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(CONTROLLED_TERMINFO.trim())
|
||||
.map_err(|error| failure(format!("controlled terminfo decoding: {error}")))?;
|
||||
let terminal = HostTerminal::from_compiled_entry(CONTROLLED_TERM, None, terminal_bytes)
|
||||
.and_then(|terminal| {
|
||||
stage_host_terminal(inputs.configuration, inputs.container_id, terminal)
|
||||
})
|
||||
.map_err(|error| failure(format!("controlled terminfo staging: {error}")))?;
|
||||
let request = SandboxBuildRequest {
|
||||
configuration: inputs.configuration,
|
||||
runsc_program: inputs.runsc_program,
|
||||
container_id: inputs.container_id,
|
||||
expected_network_namespace,
|
||||
root_process: inputs.root_process,
|
||||
terminal: None,
|
||||
terminal: Some(terminal),
|
||||
prepared_root: inputs.prepared_artifact_label.to_owned(),
|
||||
};
|
||||
let sandbox_bundle = match &git_routing {
|
||||
|
|
|
|||
|
|
@ -661,6 +661,7 @@ impl TerminalDomainStore for HostCoordinatorDomainStore {
|
|||
|
||||
mod engine;
|
||||
mod interactive;
|
||||
mod retained;
|
||||
mod runsc;
|
||||
|
||||
use engine::release_physical;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ impl SessionTerminalEngine {
|
|||
process,
|
||||
releasing: false,
|
||||
pending_input: VecDeque::new(),
|
||||
pending_render: VecDeque::new(),
|
||||
retained: super::retained::RetainedTerminal::new(
|
||||
request.initial_size.rows.get(),
|
||||
request.initial_size.columns.get(),
|
||||
),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
|
|
@ -204,6 +209,10 @@ impl SessionTerminalEngine {
|
|||
}
|
||||
SessionTerminalEventV2::Resize(resize) => {
|
||||
terminal.process.resize(resize)?;
|
||||
terminal
|
||||
.retained
|
||||
.resize(resize.rows.get(), resize.columns.get());
|
||||
terminal.pending_render.clear();
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEventV2::Break(_) => {
|
||||
|
|
@ -236,6 +245,12 @@ impl SessionTerminalEngine {
|
|||
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some(restoration) = terminal.retained.take_restoration() {
|
||||
terminal.pending_render.extend(restoration);
|
||||
}
|
||||
if let Some(message) = take_rendered_output(terminal, session_id) {
|
||||
return Ok(vec![message]);
|
||||
}
|
||||
flush_input(terminal)?;
|
||||
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
||||
match terminal.process.read_output(&mut bytes) {
|
||||
|
|
@ -246,10 +261,18 @@ impl SessionTerminalEngine {
|
|||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
),
|
||||
Ok(length) => Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes[..length].to_vec(),
|
||||
})]),
|
||||
Ok(length) => {
|
||||
let (rendered, replies) = terminal.retained.process(&bytes[..length]);
|
||||
if terminal.pending_input.len() + replies.len() > MAX_INPUT_QUEUE_BYTES {
|
||||
return Err(TerminalError::Backpressure);
|
||||
}
|
||||
terminal.pending_input.extend(replies);
|
||||
flush_input(terminal)?;
|
||||
terminal.pending_render.extend(rendered);
|
||||
Ok(take_rendered_output(terminal, session_id)
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
||||
Err(_) => self.close(
|
||||
session_id,
|
||||
|
|
@ -345,7 +368,12 @@ impl SessionTerminalEngine {
|
|||
Err(with_cleanup(error, cleanup))
|
||||
}
|
||||
Ok(route) => {
|
||||
self.active_mut(session_id, generation_id)?.route = route;
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
if terminal.route == TerminalRoute::Sandbox && route != TerminalRoute::Sandbox {
|
||||
terminal.pending_render.clear();
|
||||
terminal.retained.obscure_client();
|
||||
}
|
||||
terminal.route = route;
|
||||
Ok(route)
|
||||
}
|
||||
}
|
||||
|
|
@ -403,6 +431,7 @@ impl SessionTerminalEngine {
|
|||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()));
|
||||
let result = (|| {
|
||||
let presentation_route = terminal.route;
|
||||
terminal.releasing = true;
|
||||
terminal.route = TerminalRoute::Detached;
|
||||
let release =
|
||||
|
|
@ -418,12 +447,33 @@ impl SessionTerminalEngine {
|
|||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()
|
||||
};
|
||||
let (mut output, exit, guest_absent) = release_physical(
|
||||
let (output, exit, guest_absent) = release_physical(
|
||||
terminal.process.as_mut(),
|
||||
session_id,
|
||||
connected,
|
||||
&mut authorize_guest,
|
||||
)?;
|
||||
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
|
||||
if connected && presentation_route != TerminalRoute::Sandbox {
|
||||
terminal.retained.obscure_client();
|
||||
rendered.extend(terminal.retained.take_restoration().unwrap_or_default());
|
||||
}
|
||||
for message in output {
|
||||
if let SessionTerminalMessage::Output(output) = message {
|
||||
let (bytes, _) = terminal.retained.process(&output.bytes);
|
||||
rendered.extend(bytes);
|
||||
}
|
||||
}
|
||||
rendered.truncate(MAX_DRAIN_BYTES);
|
||||
let mut output = rendered
|
||||
.chunks(MAX_OUTPUT_BYTES)
|
||||
.map(|bytes| {
|
||||
SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if guest_absent {
|
||||
let clean = clean_terminal(generation_id, session_id)?;
|
||||
self.recorder.clean(clean)?;
|
||||
|
|
@ -442,6 +492,19 @@ impl SessionTerminalEngine {
|
|||
}
|
||||
}
|
||||
|
||||
fn take_rendered_output(
|
||||
terminal: &mut ActiveTerminal,
|
||||
session_id: SessionId,
|
||||
) -> Option<SessionTerminalMessage> {
|
||||
let length = terminal.pending_render.len().min(MAX_OUTPUT_BYTES);
|
||||
(length != 0).then(|| {
|
||||
SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: terminal.pending_render.drain(..length).collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn release_physical(
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
session_id: SessionId,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use super::retained::RetainedTerminal;
|
||||
use super::{
|
||||
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
|
||||
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
|
||||
|
|
@ -18,6 +19,8 @@ pub(super) struct ActiveTerminal {
|
|||
pub(super) process: Box<dyn SessionTerminalProcess>,
|
||||
pub(super) releasing: bool,
|
||||
pub(super) pending_input: VecDeque<u8>,
|
||||
pub(super) pending_render: VecDeque<u8>,
|
||||
pub(super) retained: RetainedTerminal,
|
||||
}
|
||||
|
||||
pub struct SessionTerminalEngine {
|
||||
|
|
|
|||
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const SYNCHRONIZED_OUTPUT_START: &[u8] = b"\x1b[?2026h";
|
||||
const SYNCHRONIZED_OUTPUT_END: &[u8] = b"\x1b[?2026l";
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalCallbacks {
|
||||
replies: Vec<u8>,
|
||||
}
|
||||
|
||||
impl vt100::Callbacks for TerminalCallbacks {
|
||||
fn unhandled_csi(
|
||||
&mut self,
|
||||
screen: &mut vt100::Screen,
|
||||
intermediate_one: Option<u8>,
|
||||
intermediate_two: Option<u8>,
|
||||
parameters: &[&[u16]],
|
||||
character: char,
|
||||
) {
|
||||
let first = parameters
|
||||
.first()
|
||||
.and_then(|parameter| parameter.first())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if intermediate_one.is_none()
|
||||
&& intermediate_two.is_none()
|
||||
&& character == 'n'
|
||||
&& first == 6
|
||||
{
|
||||
let (row, column) = screen.cursor_position();
|
||||
self.replies
|
||||
.extend_from_slice(format!("\x1b[{};{}R", row + 1, column + 1).as_bytes());
|
||||
} else if intermediate_one.is_none()
|
||||
&& intermediate_two.is_none()
|
||||
&& character == 'c'
|
||||
&& first == 0
|
||||
{
|
||||
self.replies.extend_from_slice(b"\x1b[?1;2c");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RetainedTerminal {
|
||||
parser: vt100::Parser<TerminalCallbacks>,
|
||||
client_initialized: bool,
|
||||
restore_pending: bool,
|
||||
}
|
||||
|
||||
impl RetainedTerminal {
|
||||
pub(super) fn new(rows: u16, columns: u16) -> Self {
|
||||
Self {
|
||||
parser: vt100::Parser::new_with_callbacks(
|
||||
rows,
|
||||
columns,
|
||||
0,
|
||||
TerminalCallbacks::default(),
|
||||
),
|
||||
client_initialized: false,
|
||||
restore_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let previous = self.parser.screen().clone();
|
||||
self.parser.process(bytes);
|
||||
let rendered = if self.client_initialized {
|
||||
self.parser.screen().state_diff(&previous)
|
||||
} else {
|
||||
self.client_initialized = true;
|
||||
self.redraw()
|
||||
};
|
||||
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
|
||||
(rendered, replies)
|
||||
}
|
||||
|
||||
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
||||
self.parser.screen_mut().set_size(rows, columns);
|
||||
if self.client_initialized {
|
||||
self.restore_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn obscure_client(&mut self) {
|
||||
self.restore_pending = true;
|
||||
}
|
||||
|
||||
pub(super) fn take_restoration(&mut self) -> Option<Vec<u8>> {
|
||||
if !self.restore_pending {
|
||||
return None;
|
||||
}
|
||||
self.restore_pending = false;
|
||||
self.client_initialized = true;
|
||||
Some(self.redraw())
|
||||
}
|
||||
|
||||
fn redraw(&self) -> Vec<u8> {
|
||||
let mut rendered = Vec::new();
|
||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_START);
|
||||
rendered.extend_from_slice(b"\x1b[2J\x1b[H");
|
||||
rendered.extend(self.parser.screen().state_formatted());
|
||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_END);
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RetainedTerminal;
|
||||
|
||||
#[test]
|
||||
fn parser_and_rendering_are_fragmentation_independent() {
|
||||
let bytes = b"\x1b[2J\x1b[Hplain\xe7\x95\x8c\x1b[2;3H\x1b[1;38;2;12;34;56mstyled\x1b[0m";
|
||||
let expected = retained_state(&[bytes]);
|
||||
for split in 0..=bytes.len() {
|
||||
assert_eq!(
|
||||
retained_state(&[&bytes[..split], &bytes[split..]]),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoration_reproduces_normal_and_alternate_screen_state() {
|
||||
for bytes in [
|
||||
b"normal\x1b[3;4H\x1b[?25l".as_slice(),
|
||||
b"normal\x1b[?1049h\x1b[2J\x1b[Halternate\x1b[4;5H".as_slice(),
|
||||
] {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
let (initial, _) = terminal.process(bytes);
|
||||
terminal.obscure_client();
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
assert_eq!(rendered_state(&initial), rendered_state(&restored));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_replies_derive_from_retained_cursor() {
|
||||
let mut terminal = RetainedTerminal::new(24, 80);
|
||||
let (_, replies) = terminal.process(b"\x1b[7;11H\x1b[6n\x1b[c");
|
||||
assert_eq!(replies, b"\x1b[7;11R\x1b[?1;2c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_restores_at_the_new_size() {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
terminal.process(b"content\x1b[6;7H");
|
||||
terminal.resize(6, 24);
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
let mut parser = vt100::Parser::new(6, 24, 0);
|
||||
parser.process(&restored);
|
||||
assert_eq!(parser.screen().size(), (6, 24));
|
||||
assert!(parser.screen().contents().contains("content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_before_guest_output_restores_a_blank_sandbox_screen() {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
terminal.obscure_client();
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
||||
parser.process(&restored);
|
||||
assert!(parser.screen().contents().trim().is_empty());
|
||||
}
|
||||
|
||||
fn retained_state(chunks: &[&[u8]]) -> Vec<u8> {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
for chunk in chunks {
|
||||
terminal.process(chunk);
|
||||
}
|
||||
terminal.obscure_client();
|
||||
terminal.take_restoration().unwrap()
|
||||
}
|
||||
|
||||
fn rendered_state(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
||||
parser.process(bytes);
|
||||
parser.screen().state_formatted()
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,9 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
|||
.argument(format!("PATH={}", crate::sandbox_init::DEFAULT_AGENT_PATH));
|
||||
}
|
||||
for (name, value) in &request.environment {
|
||||
if matches!(name.as_str(), "TERM" | "TERMINFO" | "TERMINFO_DIRS") {
|
||||
continue;
|
||||
}
|
||||
let mut assignment = OsString::from(name);
|
||||
assignment.push("=");
|
||||
assignment.push(value.expose());
|
||||
|
|
@ -70,6 +73,10 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
|||
request.internal_pid_file.display()
|
||||
))
|
||||
.argument("-env")
|
||||
.argument("TERM=tmux-256color")
|
||||
.argument("-env")
|
||||
.argument("TERMINFO_DIRS=/run/lazaret/terminfo:")
|
||||
.argument("-env")
|
||||
.argument(format!(
|
||||
"LAZARET_SESSION_ID={}",
|
||||
hex_lower(request.session_id.as_bytes())
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ fn insert_attached_sessions(store: &HostCoordinatorDomainStore, session_ids: &[S
|
|||
|
||||
struct MockState {
|
||||
output: VecDeque<Vec<u8>>,
|
||||
input: Vec<u8>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
guest: MockGuestState,
|
||||
resources: MockResourceState,
|
||||
|
|
@ -332,9 +333,9 @@ impl TerminalDomainStore for FailingRunningStore {
|
|||
|
||||
impl SessionTerminalProcess for MockProcess {
|
||||
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
let state = &mut *self.state.lock().unwrap();
|
||||
state.input.extend_from_slice(bytes);
|
||||
state
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
|
|
@ -499,6 +500,7 @@ impl SessionTerminalFactory for MockFactory {
|
|||
recorder.put(running.clone())?;
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
input: Vec::new(),
|
||||
log: Arc::clone(&self.log),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ fn running_store_failures_release_every_live_partial_handle() {
|
|||
.unwrap();
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
input: Vec::new(),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
|
|
|
|||
|
|
@ -24,26 +24,79 @@ fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
|||
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
engine.poll(second, GenerationId::new(7)).unwrap(),
|
||||
vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id: second,
|
||||
bytes: b"other".to_vec(),
|
||||
})]
|
||||
);
|
||||
let second_output = output_bytes(engine.poll(second, GenerationId::new(7)).unwrap());
|
||||
assert_eq!(screen_contents(&second_output), "other");
|
||||
fixture.set_session_phase(first, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let mut merged = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut rendered = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||
panic!("expected one merged output message");
|
||||
};
|
||||
merged.extend_from_slice(&output.bytes);
|
||||
rendered.extend_from_slice(&output.bytes);
|
||||
}
|
||||
assert_eq!(merged, b"onetwo");
|
||||
assert_eq!(screen_contents(&rendered), "onetwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_and_host_process_presentations_restore_the_retained_screen() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().output.push_back(
|
||||
b"\x1b[2J\x1b[Hnormal\x1b[?1049h\x1b[2J\x1b[H\x1b[1;38;2;12;34;56malternate\x1b[0m\x1b[4;5H\x1b[?25l".to_vec(),
|
||||
);
|
||||
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||
let mut client = vt100::Parser::new(24, 80, 0);
|
||||
client.process(&initial);
|
||||
let expected = client.screen().state_formatted();
|
||||
|
||||
fixture.set_session_phase(session, SessionPhase::Prompting);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
client.process(b"\x1b[2J\x1b[HPROMPT\x1b[24;80H");
|
||||
fixture.set_session_phase(session, SessionPhase::HostExecuting);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
|
||||
.unwrap();
|
||||
client.process(b"\x1b[2J\x1b[HHOST PROCESS\x1b[?25h");
|
||||
assert!(
|
||||
engine
|
||||
.poll(session, GenerationId::new(7))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
fixture.set_session_phase(session, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let restored = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||
client.process(&restored);
|
||||
assert_eq!(client.screen().state_formatted(), expected);
|
||||
assert!(factory.state(0).lock().unwrap().input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_queries_reply_to_the_guest_from_retained_state() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.push_back(b"\x1b[7;11H\x1b[6n\x1b[c".to_vec());
|
||||
engine.poll(session, GenerationId::new(7)).unwrap();
|
||||
assert_eq!(
|
||||
factory.state(0).lock().unwrap().input,
|
||||
b"\x1b[7;11R\x1b[?1;2c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -66,14 +119,16 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
||||
assert!(messages.iter().all(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => output.bytes.len() <= MAX_OUTPUT_BYTES,
|
||||
SessionTerminalMessage::Exit(_) => true,
|
||||
}));
|
||||
assert_eq!(factory.state(0).lock().unwrap().output.len(), 6);
|
||||
assert!(
|
||||
screen_contents(&output_bytes(messages.clone()))
|
||||
.chars()
|
||||
.all(|character| character == 'x' || character == '\n')
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||
);
|
||||
|
|
@ -104,6 +159,23 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
);
|
||||
}
|
||||
|
||||
fn output_bytes(messages: Vec<SessionTerminalMessage>) -> Vec<u8> {
|
||||
messages
|
||||
.into_iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn screen_contents(bytes: &[u8]) -> String {
|
||||
let mut parser = vt100::Parser::new(24, 80, 0);
|
||||
parser.process(bytes);
|
||||
parser.screen().contents()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||
for loss in [
|
||||
|
|
|
|||
|
|
@ -344,13 +344,26 @@ fn test_user_namespace_map() -> UserNamespaceMap {
|
|||
fn assert_private_artifacts(bundle: &Path, control: &Path) {
|
||||
let config = bundle.join("config.json");
|
||||
let escalation_client = control.join("bin/lazaret-escalate");
|
||||
let terminfo = control.join("terminfo/t/tmux-256color");
|
||||
assert!(config.is_file());
|
||||
assert!(escalation_client.is_file());
|
||||
assert!(terminfo.is_file());
|
||||
assert!(control.join("credentials").is_dir());
|
||||
assert_eq!(mode(bundle), 0o700);
|
||||
assert_eq!(mode(control), 0o700);
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&escalation_client), 0o700);
|
||||
assert_eq!(mode(&terminfo), 0o400);
|
||||
let specification: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(config).unwrap()).unwrap();
|
||||
let mount = specification["mounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|mount| mount["destination"] == "/run/lazaret/terminfo/t/tmux-256color")
|
||||
.unwrap();
|
||||
assert_eq!(mount["source"], terminfo.display().to_string());
|
||||
assert!(mount["options"].as_array().unwrap().contains(&"ro".into()));
|
||||
}
|
||||
|
||||
fn mode(path: &Path) -> u32 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue