Implement PTY-native session terminal control

This commit is contained in:
ms2k 2026-08-02 17:36:40 +09:00
commit 8cef037fa3
3 changed files with 1488 additions and 382 deletions

View file

@ -70,7 +70,7 @@ fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"refused".to_vec(),
}),
@ -102,7 +102,7 @@ fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"refused".to_vec(),
}),
@ -142,7 +142,7 @@ fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -183,7 +183,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -213,7 +213,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -236,9 +236,8 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
assert!(matches!(
engine.handle(
GenerationId::new(8),
SessionTerminalEvent::Signal(TerminalSignal {
session_id: session,
signal: TerminalSignalKind::Interrupt,
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session
}),
),
Err(TerminalError::StaleGeneration)
@ -266,14 +265,14 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
}
#[test]
fn resize_and_terminal_signals_use_the_exact_group_map() {
fn resize_break_and_input_use_only_the_exact_pty() {
let session = id(6);
let (_fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Resize(TerminalResize {
SessionTerminalEventV2::Resize(TerminalResize {
session_id: session,
rows: NonZeroU16::new(40).unwrap(),
columns: NonZeroU16::new(120).unwrap(),
@ -282,49 +281,49 @@ fn resize_and_terminal_signals_use_the_exact_group_map() {
}),
)
.unwrap();
for (kind, expected) in [
(TerminalSignalKind::Interrupt, "signal:INT"),
(TerminalSignalKind::Quit, "signal:QUIT"),
(TerminalSignalKind::Suspend, "signal:TSTP"),
] {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Signal(TerminalSignal {
session_id: session,
signal: kind,
}),
)
.unwrap();
assert_eq!(factory.log.lock().unwrap().last().unwrap(), expected);
}
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session,
}),
)
.unwrap();
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: vec![3],
}),
)
.unwrap();
assert_eq!(
&factory.log.lock().unwrap()[..2],
&["resize:40x120", "signal:WINCH"]
&factory.log.lock().unwrap()[..3],
&["resize:40x120", "break", "input:1"]
);
}
#[test]
fn changed_guest_identity_refuses_numeric_group_action() {
fn keyboard_input_does_not_target_the_recorded_group() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory.state(0).lock().unwrap().guest.identity_mismatch = true;
assert!(matches!(
engine.handle(
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Signal(TerminalSignal {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
signal: TerminalSignalKind::Interrupt,
bytes: vec![3],
}),
),
Err(TerminalError::GuestIdentityMismatch)
));
assert!(factory.log.lock().unwrap().is_empty());
)
.unwrap();
assert_eq!(factory.log.lock().unwrap().as_slice(), ["input:1"]);
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -341,6 +340,47 @@ fn changed_guest_identity_refuses_numeric_group_action() {
);
}
#[test]
fn mismatched_leader_with_absent_group_closes_clean() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
{
let state = factory.state(0);
let mut state = state.lock().unwrap();
state.guest.identity_mismatch = true;
state.guest.group_present = false;
}
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
.unwrap();
assert!(
messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
);
assert!(!engine.contains(session));
assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::Clean
);
assert!(
!factory
.log
.lock()
.unwrap()
.iter()
.any(|action| action.starts_with("signal:"))
);
}
#[test]
fn absent_leader_with_live_group_closes_with_release_intent() {
let session = id(6);
@ -355,7 +395,7 @@ fn absent_leader_with_live_group_closes_with_release_intent() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -412,7 +452,7 @@ fn persistent_guest_group_closes_session_with_release_intent() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -430,6 +470,40 @@ fn persistent_guest_group_closes_session_with_release_intent() {
);
}
#[test]
fn sandbox_absence_cleans_retained_terminal_after_exact_exec_absence() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory.state(0).lock().unwrap().guest.clears_after_signal = "never";
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
.unwrap();
engine
.clean_after_sandbox_absence(
GenerationId::new(7),
ContainerId::new(bytes(9)),
process(70),
|_| {
Err(io::Error::from_raw_os_error(
rustix::io::Errno::SRCH.raw_os_error(),
))
},
)
.unwrap();
assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::Clean
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn running_store_failures_release_every_live_partial_handle() {

View file

@ -61,7 +61,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -91,7 +91,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"reattach".to_vec(),
}),

View file

@ -95,7 +95,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
session_id: binding.session_id,
};
let batch = control
.exchange(InteractiveSessionEvent::Poll {
.exchange(InteractiveSessionEventV2::Poll {
session_id: binding.session_id,
})
.unwrap();
@ -111,8 +111,8 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
pixel_height: 900,
};
control
.exchange(InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Resize(resumed_size),
.exchange(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(resumed_size),
))
.unwrap();
let signed_decision = encode_signed(
@ -128,7 +128,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
)
.unwrap();
control
.exchange(InteractiveSessionEvent::PromptDecision {
.exchange(InteractiveSessionEventV2::PromptDecision {
session_id: binding.session_id,
signed_decision: signed_decision.clone(),
})
@ -140,10 +140,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
assert_eq!(routed.signed_decision, signed_decision);
assert_eq!(routed.decision.decision, DeveloperDecisionKind::Deny);
assert_eq!(routed.resumed_size, Some(resumed_size));
assert_eq!(
factory.log.lock().unwrap().as_slice(),
["resize:55x144", "signal:WINCH"]
);
assert_eq!(factory.log.lock().unwrap().as_slice(), ["resize:55x144"]);
assert!(
!factory
.log
@ -169,7 +166,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
.unwrap()
.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: binding.session_id,
bytes: b"sandbox-input".to_vec(),
}),