diff --git a/crates/bitty-platform/Cargo.toml b/crates/bitty-platform/Cargo.toml index c699fb84..3261aa0e 100644 --- a/crates/bitty-platform/Cargo.toml +++ b/crates/bitty-platform/Cargo.toml @@ -22,6 +22,8 @@ gui-tests = [] # (`App::run` -> DisplayUnavailable) without requiring a window system. # CI never enables gui-tests; the default run proves headless still works. headless = [] +# In-memory EventContext backend; never enabled by default release builds. +input-vm-test-support = [] # Runs as a plain process entry point (`harness = false`) because winit # requires event-loop creation on the OS main thread; see the file header. diff --git a/crates/bitty-platform/src/app.rs b/crates/bitty-platform/src/app.rs index f455b4c3..74d6c03f 100644 --- a/crates/bitty-platform/src/app.rs +++ b/crates/bitty-platform/src/app.rs @@ -125,11 +125,47 @@ fn map_loop_error(error: EventLoopError) -> PlatformError { /// Borrowed per callback; methods are cheap and never block. #[derive(Debug)] pub struct EventContext<'a> { - event_loop: &'a ActiveEventLoop, - registry: &'a mut Registry, + backend: EventContextBackend<'a>, +} + +#[derive(Debug)] +enum EventContextBackend<'a> { + Live { + event_loop: &'a ActiveEventLoop, + registry: &'a mut Registry, + }, + #[cfg(feature = "input-vm-test-support")] + Recording(&'a std::cell::RefCell>), +} + +/// Loop requests recorded without constructing an OS event loop or window. +#[cfg(feature = "input-vm-test-support")] +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RecordingOperation { + /// Window creation was requested and refused with `WindowCreation`. + WindowCreationRefused, + /// Finish the event loop. + Exit, + /// Busy-poll. + Poll, + /// Wait indefinitely. + Wait, + /// Wait until the given monotonic deadline. + WaitUntil(std::time::Instant), } impl EventContext<'_> { + /// Constructs an in-memory context for explicitly opted-in handler tests. + /// This creates no registry, window, OS loop, proxy, or desktop sink. + #[cfg(feature = "input-vm-test-support")] + #[doc(hidden)] + pub fn recording(operations: &std::cell::RefCell>) -> EventContext<'_> { + EventContext { + backend: EventContextBackend::Recording(operations), + } + } + /// Creates a window according to `config`. /// /// # Errors @@ -137,10 +173,24 @@ impl EventContext<'_> { /// Returns [`PlatformError::WindowCreation`] when the platform refuses /// the request; the upstream diagnostic text is preserved. pub fn create_window(&mut self, config: WindowConfig) -> Result { + let (event_loop, registry) = match &mut self.backend { + EventContextBackend::Live { + event_loop, + registry, + } => (event_loop, registry), + #[cfg(feature = "input-vm-test-support")] + EventContextBackend::Recording(operations) => { + operations + .borrow_mut() + .push(RecordingOperation::WindowCreationRefused); + return Err(PlatformError::WindowCreation( + "recording context refuses native window creation".to_string(), + )); + } + }; let blur_radius = config.blur_radius(); let attributes = config.into_attributes(); - let window = self - .event_loop + let window = event_loop .create_window(attributes) .map_err(|error| PlatformError::WindowCreation(error.to_string()))?; @@ -149,7 +199,7 @@ impl EventContext<'_> { crate::blur::apply_blur(&window, blur_radius); } - let id = self.registry.register(&window.id()); + let id = registry.register(&window.id()); Ok(WindowHandle { id, window: Arc::new(window), @@ -158,17 +208,23 @@ impl EventContext<'_> { /// Requests that the event loop finish after the current iteration. pub fn exit(&self) { - self.event_loop.exit(); + match &self.backend { + EventContextBackend::Live { event_loop, .. } => event_loop.exit(), + #[cfg(feature = "input-vm-test-support")] + EventContextBackend::Recording(operations) => { + operations.borrow_mut().push(RecordingOperation::Exit); + } + } } /// Puts the loop into busy-polling mode (continuous redraw loops). pub fn set_poll(&self) { - self.event_loop.set_control_flow(ControlFlow::Poll); + self.set_control_flow(ControlFlow::Poll); } /// Puts the loop back into energy-saving wait mode (default). pub fn set_wait(&self) { - self.event_loop.set_control_flow(ControlFlow::Wait); + self.set_control_flow(ControlFlow::Wait); } /// Waits until `deadline` and then delivers @@ -178,8 +234,23 @@ impl EventContext<'_> { /// busy-polling — the hover dwell delay uses it so focus still applies /// when the pointer stops moving and no further input events arrive. pub fn set_wait_until(&self, deadline: std::time::Instant) { - self.event_loop - .set_control_flow(ControlFlow::WaitUntil(deadline)); + self.set_control_flow(ControlFlow::WaitUntil(deadline)); + } + + fn set_control_flow(&self, control_flow: ControlFlow) { + match &self.backend { + EventContextBackend::Live { event_loop, .. } => { + event_loop.set_control_flow(control_flow) + } + #[cfg(feature = "input-vm-test-support")] + EventContextBackend::Recording(operations) => { + operations.borrow_mut().push(match control_flow { + ControlFlow::Poll => RecordingOperation::Poll, + ControlFlow::Wait => RecordingOperation::Wait, + ControlFlow::WaitUntil(deadline) => RecordingOperation::WaitUntil(deadline), + }); + } + } } } @@ -567,8 +638,10 @@ impl Adapter { fn dispatch(&mut self, event_loop: &ActiveEventLoop, event: PlatformEvent) { let Self { handler, registry } = self; let mut ctx = EventContext { - event_loop, - registry, + backend: EventContextBackend::Live { + event_loop, + registry, + }, }; handler.handle_event(&mut ctx, event); } diff --git a/crates/bitty-runtime/Cargo.toml b/crates/bitty-runtime/Cargo.toml index 9855910e..4c2bc903 100644 --- a/crates/bitty-runtime/Cargo.toml +++ b/crates/bitty-runtime/Cargo.toml @@ -13,6 +13,10 @@ publish = true [lints] workspace = true +[features] +# Read-only VM accounting and safe construction for the explicit regression. +input-vm-test-support = [] + # CTX-0906: hermetic native-component test fixture. Speaks wire protocol v1 # over stdio so the broker integration tests spawn a real coprocess without # any network access; never installed or packaged (publish = false, and the diff --git a/crates/bitty-runtime/src/plugin_runtime/mod.rs b/crates/bitty-runtime/src/plugin_runtime/mod.rs index 56ff44b0..d338ce82 100644 --- a/crates/bitty-runtime/src/plugin_runtime/mod.rs +++ b/crates/bitty-runtime/src/plugin_runtime/mod.rs @@ -555,6 +555,17 @@ struct PluginEntry { type DiscoveryResults = Vec<(PluginId, Result<(), PluginRuntimeError>)>; +/// A failed read-only owning-VM counter observation (never a zero fallback). +#[cfg(feature = "input-vm-test-support")] +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmExecutionCountError { + /// No entry or no VM for this plugin. + MissingVm, + /// A mutable VM borrow is active on the owning thread. + Busy, +} + /// Orchestrates discovery, activation, and dispatch for plugin VMs. /// /// The struct is `!Send` because it owns Phodopus VMs; callers use it on the @@ -760,6 +771,24 @@ pub struct PluginRuntime { } impl PluginRuntime { + /// Reads actual owning-VM entries under a short, shared owning-thread borrow. + /// No VM/reference escapes and no execution or dispatch occurs. + /// + /// # Errors + /// + /// Distinguishes missing VM from a busy mutable borrow. + #[cfg(feature = "input-vm-test-support")] + #[doc(hidden)] + pub fn vm_total_executions(&self, id: &PluginId) -> Result { + let vm = self + .entries + .get(id) + .and_then(|entry| entry.vm.as_ref()) + .ok_or(VmExecutionCountError::MissingVm)?; + let vm = vm.try_borrow().map_err(|_| VmExecutionCountError::Busy)?; + Ok(vm.total_executions()) + } + /// Create an empty runtime. #[must_use] pub fn new(config: PluginRuntimeConfig) -> Self { @@ -3886,6 +3915,55 @@ impl PluginRuntime { mod tests { use super::*; + #[cfg(feature = "input-vm-test-support")] + #[test] + fn input_vm_boundary_counter_distinguishes_missing_busy_and_zero() { + // Constructor-only control: no Lua source is evaluated or dispatched. + let mut runtime = PluginRuntime::new(PluginRuntimeConfig::default()); + let manifest = manifest_toml::parse_manifest( + br#"[plugin] +id = "bitty-featured.input-counter" +name = "Input counter fixture" +version = "0.0.1" +description = "Read-only owning-VM accounting" +[compat] +plugin-api = "^1.0" +"#, + ) + .expect("in-memory manifest"); + let id = manifest.id().clone(); + assert_eq!( + runtime.vm_total_executions(&id), + Err(VmExecutionCountError::MissingVm) + ); + runtime.insert_package( + id.clone(), + PluginPackage { + manifest, + module_root: PathBuf::new(), + source_class: SourceClass::LocalPath, + unverified: true, + granted: None, + }, + ); + assert_eq!( + runtime.vm_total_executions(&id), + Err(VmExecutionCountError::MissingVm) + ); + let vm = Rc::new(RefCell::new( + build_plugin_vm(id.as_str(), Some(VmBudgets::default())).expect("bounded pure VM"), + )); + runtime.entries.get_mut(&id).expect("entry").vm = Some(vm.clone()); + assert_eq!(runtime.vm_total_executions(&id), Ok(0)); + let busy = vm.borrow_mut(); + assert_eq!( + runtime.vm_total_executions(&id), + Err(VmExecutionCountError::Busy) + ); + drop(busy); + assert_eq!(runtime.vm_total_executions(&id), Ok(0)); + } + #[test] fn runtime_error_taxonomy_codes_and_classes() { let plugin = "xuepoo.test".to_string(); diff --git a/crates/bitty-runtime/src/runtime.rs b/crates/bitty-runtime/src/runtime.rs index 95c76816..c2e66ccd 100644 --- a/crates/bitty-runtime/src/runtime.rs +++ b/crates/bitty-runtime/src/runtime.rs @@ -1535,6 +1535,7 @@ impl Runtime { pipeline_capacity, side_capacity, false, + Clipboard::new, ) } @@ -1563,6 +1564,28 @@ impl Runtime { DEFAULT_PLUGIN_PIPELINE_CAPACITY, DEFAULT_PLUGIN_SIDE_CAPACITY, true, + Clipboard::new, + ) + } + + /// Builds only the finite input/VM regression's in-memory runtime. + /// Default configuration has no image paths or roots; deterministic glyphs, + /// software surface, and headless clipboard are selected before creation. + /// Ordinary constructors retain their original backend selection. + /// + /// # Errors + /// + /// Returns the same validation/render errors as [`Self::new`]. + #[cfg(feature = "input-vm-test-support")] + #[doc(hidden)] + pub fn for_input_vm_regression() -> Result { + Self::with_plugin_host_capacity_and_rasterizer( + RuntimeConfig::default(), + DEFAULT_PLUGIN_DROP_POLICY, + DEFAULT_PLUGIN_PIPELINE_CAPACITY, + DEFAULT_PLUGIN_SIDE_CAPACITY, + true, + Clipboard::new_headless, ) } @@ -1572,6 +1595,7 @@ impl Runtime { pipeline_capacity: usize, side_capacity: usize, deterministic_rasterizer: bool, + make_clipboard: fn() -> Clipboard, ) -> Result { config.validate()?; if pipeline_capacity == 0 || side_capacity == 0 { @@ -1666,7 +1690,7 @@ impl Runtime { layout, focus, container, - clipboard: Clipboard::new(), + clipboard: make_clipboard(), selection_state: None, click_tracker: ClickTracker::new(), last_click_count: crate::runtime::click::CLICK_COUNT_MIN, diff --git a/crates/bitty-terminal/Cargo.toml b/crates/bitty-terminal/Cargo.toml index 04be7d4b..b4ea8379 100644 --- a/crates/bitty-terminal/Cargo.toml +++ b/crates/bitty-terminal/Cargo.toml @@ -58,6 +58,8 @@ default = [] # Enables `bitty dev capture|synthesize|dump|overlay` (CTX-0922): developer # headless captures, dumps, and overlay proofs stay out of release builds. dev-tools = [] +# Explicit, default-off application boundary regression infrastructure. +input-vm-test-support = ["bitty-platform/input-vm-test-support", "bitty-runtime/input-vm-test-support"] [dev-dependencies] # Hermetic `ServeContext` constructors and unbound bearer minters (#1519). diff --git a/crates/bitty-terminal/src/terminal_app.rs b/crates/bitty-terminal/src/terminal_app.rs index b3233721..bcc6f197 100644 --- a/crates/bitty-terminal/src/terminal_app.rs +++ b/crates/bitty-terminal/src/terminal_app.rs @@ -1274,6 +1274,12 @@ impl TerminalApp { // Cold-tick only, never the input hot path (P0-AC-015); no-op // without a plugin runtime or armed timers. self.pump_plugin_timers(); + // CTX-1327 (issue #2213, P0-AC-015): deliver Core-routed plugin band + // clicks/scrolls queued by the input callback. `handle_event` only + // enqueues into the runtime's bounded band queues; the owning-VM + // dispatch runs here on the cold tick, never on the input hot path, + // so input never enters a plugin VM synchronously. + self.deliver_band_input(); // CTX-0911 (issue #1570): read mounted UiBlocks from plugin runtime, // convert to ChromeBands, and push into Runtime before present so // bands render on this frame. @@ -1954,6 +1960,31 @@ impl TerminalApp { } } + /// Delivers Core-routed plugin band clicks and scrolls queued by input + /// (CTX-1327, issue #2213). + /// + /// The runtime owns the geometry (which band row, which declared + /// command/scroll target); the input callback only enqueues into its + /// bounded band queues. This cold pump drains them (at most the bounded + /// capacity, in press/tick order) and dispatches each through the normal + /// [`Self::dispatch_band_click`] / [`Self::dispatch_band_scroll`] path, + /// where registration and capability gates fail closed as usual. + /// Cold-tick only, never the input hot path (P0-AC-015), so an enabled + /// plugin VM is never entered synchronously from input. Returns the number + /// of requests delivered. + pub(crate) fn deliver_band_input(&mut self) -> usize { + let clicks = self.runtime.drain_band_clicks(); + let scrolls = self.runtime.drain_band_scrolls(); + let delivered = clicks.len().saturating_add(scrolls.len()); + for click in clicks { + self.dispatch_band_click(click); + } + for scroll in scrolls { + self.dispatch_band_scroll(scroll); + } + delivered + } + /// Empty args value for argument-free host-mediated invocations /// (CTX-1035). /// @@ -2823,23 +2854,15 @@ impl AppHandler for TerminalApp { // else the focused pane's `OSC 22` shape, else `Default`. // Change-gated inside, so steady hover costs no OS call. self.sync_cursor_icon(); - // CTX-0946 C1: Core-routed plugin band clicks. The runtime owns the - // geometry (which band row, which declared command); the application - // dispatches each through the normal `PluginRuntime::dispatch_command` - // path, where registration and capability gates fail closed as - // usual. Dispatch failures are loud (user-paced gestures, never a - // hot loop) and never disturb terminal state. - for click in self.runtime.drain_band_clicks() { - self.dispatch_band_click(click); - } - // CTX-1190 (#2008): Core-routed plugin band scrolls. Same posture - // as clicks — the runtime owns the geometry and the host-computed - // delta; the application dispatches each through the normal - // `PluginRuntime::dispatch_command` path, where registration and - // capability gates fail closed as usual. - for scroll in self.runtime.drain_band_scrolls() { - self.dispatch_band_scroll(scroll); - } + // CTX-1327 (issue #2213, P0-AC-015): Core-routed plugin band clicks + // (CTX-0946 C1) and scrolls (CTX-1190 / #2008) are queued by the + // runtime here but deliberately NOT dispatched from this input + // callback. Routing already resolved the band owner and declared + // command into the runtime's bounded, fail-closed queues; the owning + // plugin VM is entered only on the cold tick pump + // ([`Self::deliver_band_input`], called from [`Self::drive_tick`]). + // This keeps the input hot path free of synchronous owning-VM + // execution: the invariant issue #2213 flags. // CTX-0370: a window-close request that did not exit armed a bounded // confirmation (or was superseded by one); report it loudly so the // pending gate is never silent in the log. `AboutToWait` presents the diff --git a/crates/bitty-terminal/src/tests.rs b/crates/bitty-terminal/src/tests.rs index 075f115f..96f885c2 100644 --- a/crates/bitty-terminal/src/tests.rs +++ b/crates/bitty-terminal/src/tests.rs @@ -17,6 +17,495 @@ fn args_of(words: &[&str]) -> Vec { words.iter().map(|s| (*s).to_string()).collect() } +// CTX-1327 / P0-AC-015: exercise the real application input callback through +// `AppHandler::handle_event` with a headless `EventContext` and a read-only +// owning-VM entry counter. The regression proves the accepted input path +// enqueues the resolved band request without entering the plugin VM, and that +// the deferred dispatch happens exactly once on the distinct cold pump. +#[cfg(feature = "input-vm-test-support")] +mod input_vm_boundary { + use super::*; + use bitty_platform::app::RecordingOperation; + use bitty_platform::{ + AppHandler, CursorPosition, EventContext, ModifiersState, MouseButton, MouseEvent, + PressState, ScrollDelta, WindowId, + }; + use bitty_plugin_host::manifest::PluginId; + use bitty_runtime::plugin_runtime::{ + LifecycleState, LuaValue, PluginRuntime, PluginRuntimeConfig, VmExecutionCountError, + }; + use std::cell::RefCell; + + const PLUGIN: &str = "bitty-featured.input-boundary"; + const BOUND_COLUMN: u16 = 1; + const UNBOUND_COLUMN: u16 = 7; + const WHEEL_NOTCH: f32 = 1.0; + const EXTRA_SCROLLBACK_LINES: usize = 8; + + struct Fixture { + app: TerminalApp, + id: PluginId, + root: std::path::PathBuf, + operations: RefCell>, + } + + impl Fixture { + fn new(case: &str) -> Self { + // Only fixture-owned files are touched. No user configuration, + // startup path, native clipboard, window, watcher, or PTY exists. + let root = std::env::temp_dir().join(format!("ctx1327-{}-{case}", std::process::id())); + std::fs::create_dir(&root).expect("fresh fixture directory"); + let plugin = root.join(PLUGIN); + std::fs::create_dir_all(plugin.join("lua")).expect("fixture module directory"); + std::fs::write( + plugin.join("bitty-plugin.toml"), + format!( + r#"[plugin] +id = "{PLUGIN}" +name = "Input boundary fixture" +version = "0.0.1" +description = "Finite application input boundary regression" +[compat] +plugin-api = "^1.0" +[capabilities] +ui.rich = true +[lazy] +commands = ["{PLUGIN}:step", "{PLUGIN}:read"] +events = [] +"# + ), + ) + .expect("fixture manifest"); + std::fs::write( + plugin.join("lua/init.lua"), + r#" +local count = 0 +bitty.commands.register({ id = "step", title = "Step", run = function(args) + count = count + 1 + return tostring(count) +end }) +bitty.commands.register({ id = "read", title = "Read", run = function(args) + return count +end }) +bitty.ui.mount("bottom", { kind = "Row", children = { + { kind = "Text", text = "bound", on_click = { command = "step" }, + on_scroll = { command = "step" } }, + { kind = "Text", text = " free" } +} }) +return {} +"#, + ) + .expect("finite local fixture source"); + let mut plugins = PluginRuntime::new(PluginRuntimeConfig { + third_party_roots: vec![root.clone()], + ..PluginRuntimeConfig::default() + }); + let id = PluginId::new(PLUGIN).expect("fixture identity"); + assert_eq!( + plugins.vm_total_executions(&id), + Err(VmExecutionCountError::MissingVm) + ); + let discovery = plugins.discover(); + assert_eq!(discovery.len(), 1); + assert!(discovery[0].1.is_ok(), "{discovery:?}"); + let activation = plugins.activate(&id).expect("fixture activation"); + assert_eq!(activation.state, LifecycleState::Active); + assert_eq!(activation.commands, 2); + assert_eq!(activation.events, 0); + assert_eq!(plugins.ui_blocks().len(), 1, "real authorized mount"); + assert!(plugins.timer_deadline().is_none()); + + let rt = Runtime::for_input_vm_regression().expect("safe construction"); + assert!(rt.is_headless()); + assert!(!rt.is_crossfont()); + assert!(rt.clipboard().is_headless()); + assert_eq!(rt.pty_size(), None); + assert!(!rt.has_pty_writer()); + assert_eq!(rt.background_loads(), 0); + let maps = bitty_config::resolve_keymaps(&bitty_config::EffectiveConfig::default()) + .expect("default in-memory keymap"); + let mut app = TerminalApp::with_theme( + rt, + bitty_config::theme::DEFAULT_THEME_NAME, + "fixture", + maps, + SpawnSpec::default(), + ) + .with_plugin_runtime(Some(plugins)); + app.set_session_persistence(false); + // Present the actual mounted tree without invoking startup or + // the application cold pump (no delivery mechanism is selected). + let blocks = app.plugin_runtime.as_ref().expect("plugins").ui_blocks(); + let (bands, unplaced) = bitty_runtime::ChromeBands::from_mounts( + blocks + .into_iter() + .map(|(id, slot, node, version)| (id.to_string(), slot, node, version)), + ); + assert_eq!(unplaced, 0); + app.runtime.set_chrome_bands(bands); + app.runtime + .tick() + .expect("deterministic mount presentation"); + let fixture = Self { + app, + id, + root, + operations: RefCell::new(Vec::new()), + }; + fixture.assert_no_sinks(); + println!( + "{case}: baseline after activation/mount={}", + fixture.entries() + ); + fixture + } + + fn entries(&self) -> u64 { + self.app + .plugin_runtime + .as_ref() + .expect("plugins") + .vm_total_executions(&self.id) + .expect("owning VM must exist and be idle") + } + + fn event(&mut self, kind: WindowEventKind) -> u64 { + let before = self.entries(); + let event = PlatformEvent::Window { + window_id: WindowId::from_raw_public(1), + kind, + }; + let mut context = EventContext::recording(&self.operations); + ::handle_event(&mut self.app, &mut context, event); + let after = self.entries(); + self.assert_no_sinks(); + after.checked_sub(before).expect("monotonic VM counter") + } + + fn cursor(&mut self, column: u16) { + let rt = &self.app.runtime; + let row = rt + .plugin_band_row(bitty_runtime::BandEdge::Bottom, 0) + .expect("mounted bottom row"); + let (cw, ch) = rt.live_cell_size(); + let pad = f64::from(rt.window_padding_physical()); + let delta = self.event(WindowEventKind::CursorMoved(CursorPosition { + x: pad + (f64::from(column) + 0.5) * f64::from(cw), + y: pad + (f64::from(row) + 0.5) * f64::from(ch), + })); + assert_eq!(delta, 0, "cursor input must not enter a VM"); + } + + fn mouse(&mut self, state: PressState) -> u64 { + self.event(WindowEventKind::MouseInput(MouseEvent::new( + MouseButton::Left, + state, + ))) + } + + fn assert_no_sinks(&self) { + assert!(self.app.window.handle.is_none()); + assert!(self.app.window.os_title_sink.is_none()); + assert!(self.app.window.os_cursor_sink.is_none()); + assert!(self.app.pty_rx.is_none()); + assert!(self.app._pty_thread.is_none()); + assert_eq!(self.app.runtime.pty_size(), None); + assert!(!self.app.runtime.has_pty_writer()); + assert!(self.operations.borrow().is_empty()); + } + + fn read_marker(&mut self) -> i64 { + let before = self.entries(); + let result = self + .app + .plugin_runtime + .as_mut() + .expect("plugins") + .dispatch_command(&self.id, "read", &[]) + .expect("separate marker read"); + assert_eq!(self.entries() - before, 1, "marker read counted separately"); + let LuaValue::Integer(marker) = result else { + panic!("bounded integer marker expected: {result:?}"); + }; + marker + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.root).expect("remove only owned fixture files"); + } + } + + #[derive(Clone, Copy, Debug)] + enum Gesture { + ClickRelease, + Wheel, + } + + fn input_must_not_enter_vm(gesture: Gesture) { + let mut fixture = Fixture::new(match gesture { + Gesture::ClickRelease => "release", + Gesture::Wheel => "wheel", + }); + // Mouse reporting makes an orphan release observable in pending PTY + // bytes, without starting a process or attaching a writer. + fixture + .app + .runtime + .handle_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); + fixture.cursor(BOUND_COLUMN); + fixture.app.runtime.start_selection(Default::default()); + let mut selected = fixture.app.runtime.selection().expect("selection anchor"); + selected.focus.col = 1; + fixture.app.runtime.set_selection(selected); + assert!(fixture.app.runtime.has_selection()); + fixture + .app + .runtime + .handle_ime_preedit(Some("composing".to_string()), Some(2)); + let focus = fixture.app.runtime.focused_view(); + let window_focus = fixture.app.runtime.is_window_focused(); + let selection = fixture.app.runtime.selection(); + let ime = fixture.app.runtime.ime_preedit().map(str::to_string); + let ime_area = fixture.app.runtime.ime_cursor_area(); + let window_ime_area = fixture.app.window.ime_cursor_area; + let before = fixture.entries(); + let delta = match gesture { + Gesture::ClickRelease => { + assert_eq!(fixture.mouse(PressState::Pressed), 0); + assert!(fixture.app.runtime.band_release_armed()); + let delta = fixture.mouse(PressState::Released); + assert!(!fixture.app.runtime.band_release_armed()); + assert_eq!(fixture.app.runtime.band_host_stats().clicks_routed, 1); + delta + } + Gesture::Wheel => { + let delta = fixture.event(WindowEventKind::MouseWheel(ScrollDelta::Lines( + 0.0, + WHEEL_NOTCH, + ))); + assert_eq!(fixture.app.runtime.band_host_stats().scrolls_routed, 1); + delta + } + }; + // (a) ZERO owning-VM entries during input: the accepted gesture routed + // a bounded request into the runtime queue without entering the VM. + assert_eq!( + delta, 0, + "P0-AC-015: input callback must not enter owning VM" + ); + assert_eq!(fixture.entries(), before, "no owning-VM entry during input"); + assert!( + fixture.app.runtime.pending_input().is_empty(), + "no PTY report" + ); + assert_eq!(fixture.app.runtime.focused_view(), focus); + assert_eq!(fixture.app.runtime.is_window_focused(), window_focus); + assert_eq!(fixture.app.runtime.selection(), selection); + assert_eq!(fixture.app.runtime.ime_preedit(), ime.as_deref()); + assert_eq!(fixture.app.runtime.ime_cursor_area(), ime_area); + assert_eq!(fixture.app.window.ime_cursor_area, window_ime_area); + println!( + "{gesture:?}: owning VM input delta={delta}; routing accepted; PTY/selection/focus/IME unchanged" + ); + // (b) exactly-once later cold delivery: the distinct cold pump enters + // the VM once and the queued command's bounded result surfaces. + assert_eq!( + fixture.app.deliver_band_input(), + 1, + "one bounded request queued by the accepted input" + ); + assert_eq!( + fixture.entries(), + before + 1, + "exactly one owning-VM entry on the cold pump" + ); + fixture.assert_no_sinks(); + assert_eq!( + fixture.app.runtime.notification_banner().as_deref(), + Some("bitty-featured.input-boundary:step: 1"), + "the deferred command's bounded result surfaced" + ); + // A second pump must not replay the already-consumed request. + assert_eq!( + fixture.app.deliver_band_input(), + 0, + "cold pump delivers exactly once" + ); + assert_eq!( + fixture.entries(), + before + 1, + "no replay on a second cold pump" + ); + // (c) the deferred command ran exactly once: the plugin's own counter, + // read through a separate VM entry, matches one delivery. + let marker = fixture.read_marker(); + assert_eq!(marker, 1, "deferred command ran exactly once"); + println!("{gesture:?}: separate marker read delta=1, marker={marker}"); + } + + #[test] + fn input_vm_boundary_click_release_excludes_vm_entry() { + input_must_not_enter_vm(Gesture::ClickRelease); + } + + #[test] + fn input_vm_boundary_wheel_excludes_vm_entry() { + input_must_not_enter_vm(Gesture::Wheel); + } + + #[test] + fn input_vm_boundary_cold_pump_delivers_exactly_once() { + // (b) exactly-once later cold delivery, isolated: a bound wheel queues + // one bounded request; the distinct cold pump enters the owning VM + // exactly once and never replays the consumed request. + let mut fixture = Fixture::new("coldpump"); + fixture.cursor(BOUND_COLUMN); + let before = fixture.entries(); + assert_eq!( + fixture.event(WindowEventKind::MouseWheel(ScrollDelta::Lines( + 0.0, + WHEEL_NOTCH + ))), + 0, + "input must not enter the owning VM" + ); + assert_eq!(fixture.app.runtime.band_host_stats().scrolls_routed, 1); + assert_eq!(fixture.entries(), before, "no VM entry during input"); + assert_eq!(fixture.app.deliver_band_input(), 1, "one deferred delivery"); + assert_eq!(fixture.entries(), before + 1, "exactly one VM entry"); + assert_eq!(fixture.app.deliver_band_input(), 0, "no replay"); + assert_eq!(fixture.entries(), before + 1, "still exactly one"); + assert_eq!(fixture.read_marker(), 1); + println!("coldpump: input delta=0; cold pump delta=1 exactly once; marker=1"); + } + + #[test] + fn input_vm_boundary_direct_command_positive_control() { + let mut fixture = Fixture::new("direct"); + let before = fixture.entries(); + let result = fixture + .app + .plugin_runtime + .as_mut() + .expect("plugins") + .dispatch_command(&fixture.id, "step", &[]) + .expect("valid direct command"); + assert_eq!(fixture.entries() - before, 1); + assert_eq!(result, LuaValue::String("1".to_string())); + assert_eq!(fixture.read_marker(), 1); + println!( + "direct: one VM entry, bounded result 1; separate marker read: one VM entry, marker 1" + ); + } + + #[test] + fn input_vm_boundary_unbound_and_shift_fallthrough_controls() { + let mut fixture = Fixture::new("fallthrough"); + let seed_lines = fixture.app.runtime.snapshot().height + EXTRA_SCROLLBACK_LINES; + fixture + .app + .runtime + .handle_pty_bytes(&b"fixture\r\n".repeat(seed_lines)); + fixture + .app + .runtime + .handle_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); + fixture.cursor(UNBOUND_COLUMN); + let before = fixture.entries(); + assert_eq!( + fixture.event(WindowEventKind::MouseWheel(ScrollDelta::Lines( + 0.0, + WHEEL_NOTCH + ))), + 0 + ); + assert_eq!(fixture.app.runtime.band_host_stats().scrolls_unclaimed, 1); + assert!( + !fixture.app.runtime.pending_input().is_empty(), + "unbound wheel reaches mouse reporting" + ); + fixture.app.runtime.drain_pending_input(); + fixture.cursor(BOUND_COLUMN); + assert_eq!( + fixture.event(WindowEventKind::ModifiersChanged(ModifiersState { + shift: true, + control: false, + alt: false, + super_pressed: false, + })), + 0 + ); + assert_eq!(fixture.mouse(PressState::Pressed), 0); + assert!( + !fixture.app.runtime.band_release_armed(), + "Shift selects terminal escape" + ); + assert_eq!(fixture.mouse(PressState::Released), 0); + assert_eq!( + fixture.event(WindowEventKind::MouseWheel(ScrollDelta::Lines( + 0.0, + WHEEL_NOTCH + ))), + 0 + ); + assert_eq!(fixture.app.runtime.band_host_stats().clicks_routed, 0); + assert_eq!(fixture.app.runtime.band_host_stats().scrolls_routed, 0); + let focused = fixture.app.runtime.focused_view().expect("focused leaf"); + assert!( + fixture + .app + .runtime + .layout() + .find_leaf(focused) + .expect("live leaf") + .scroll_offset() + > 0, + "Shift wheel reaches the terminal scrollback viewport" + ); + assert!( + fixture.app.runtime.pending_input().is_empty(), + "Shift emits no mouse report" + ); + assert_eq!(fixture.entries(), before); + assert_eq!( + fixture.app.deliver_band_input(), + 0, + "fall-through queued nothing for the cold pump" + ); + assert_eq!(fixture.read_marker(), 0); + println!( + "fallthrough: unbound wheel reports to in-memory terminal input; Shift scrolls the terminal viewport; zero input VM entries; marker 0" + ); + } + + #[test] + fn input_vm_boundary_recording_context_refuses_windows() { + let operations = RefCell::new(Vec::new()); + let mut context = EventContext::recording(&operations); + assert!(matches!( + context.create_window(bitty_platform::WindowConfig::new()), + Err(bitty_platform::PlatformError::WindowCreation(_)) + )); + context.set_poll(); + context.set_wait(); + let deadline = std::time::Instant::now(); + context.set_wait_until(deadline); + context.exit(); + assert_eq!( + *operations.borrow(), + vec![ + RecordingOperation::WindowCreationRefused, + RecordingOperation::Poll, + RecordingOperation::Wait, + RecordingOperation::WaitUntil(deadline), + RecordingOperation::Exit, + ] + ); + } +} + #[test] fn parse_no_args_yields_defaults() { let raw = args_of(&["bitty"]);