diff --git a/crates/bitty-lua/src/ui.rs b/crates/bitty-lua/src/ui.rs index 9fa16d8c..9ef4868c 100644 --- a/crates/bitty-lua/src/ui.rs +++ b/crates/bitty-lua/src/ui.rs @@ -30,6 +30,13 @@ //! slice enforces the same numbers per plugin generation, which is a strict //! subset of the terminal-wide budget. The terminal-wide aggregation across //! generations is owned by the host composer and is not part of this slice. +//! +//! The local `SCN-4` aggregate charges every retained string in a mounted +//! subtree, not only `Text` content: theme tokens, click/scroll command +//! identifiers, argument keys, and string argument values are all retained +//! host-side and count against the same 2 MiB number (CTX-1340). This is a +//! strict superset of the old text-only measurement and invents no new +//! ceiling. use phodopus::Value; @@ -136,8 +143,12 @@ pub const UI_MAX_TEXT_BYTES: usize = 256 * 1024; /// enforced here per generation as a strict subset). pub const UI_MAX_BLOCKS: usize = 64; -/// Maximum aggregated text bytes per plugin generation (`SCN-4` per terminal, -/// enforced here per generation as a strict subset). +/// Maximum aggregated retained string bytes per plugin generation (`SCN-4` +/// per terminal, enforced here per generation as a strict subset). +/// +/// The name keeps the accepted `SCN-4` "text" dimension, but the local +/// accounting charges every retained string (see +/// [`UiNode::retained_string_bytes`]); the 2 MiB number is unchanged. pub const UI_MAX_AGGREGATED_TEXT_BYTES: usize = 2 * 1024 * 1024; /// Maximum click command identifier length (CTX-0890). @@ -149,6 +160,15 @@ pub const UI_CLICK_MAX_ARGS: usize = 16; /// Maximum click command argument value length (CTX-0890). pub const UI_CLICK_MAX_ARG_BYTES: usize = 256; +/// Maximum click/scroll command argument key length (CTX-1340). +/// +/// Argument keys are identifiers on the same command surface as the command +/// identifier, so they reuse [`UI_CLICK_MAX_COMMAND_BYTES`] instead of being +/// uncapped: this introduces no new numeric ceiling. Values keep their +/// separate [`UI_CLICK_MAX_ARG_BYTES`] bound, and both bounds feed the existing +/// aggregate rather than adding a dimension to it. +pub const UI_CLICK_MAX_ARG_KEY_BYTES: usize = UI_CLICK_MAX_COMMAND_BYTES; + /// Maximum theme token name length (CTX-0890). pub const UI_MAX_THEME_TOKEN_BYTES: usize = 32; @@ -201,6 +221,26 @@ pub enum ClickArg { impl Eq for ClickArg {} +impl ClickCommand { + /// Bytes of the retained command identifier, argument keys, and string + /// argument values. Numeric and boolean argument values retain no string + /// bytes, but their keys are still charged. + fn retained_string_bytes(&self) -> usize { + self.command.len() + + self + .args + .iter() + .map(|(key, value)| { + key.len() + + match value { + ClickArg::String(text) => text.len(), + ClickArg::Number(_) | ClickArg::Bool(_) => 0, + } + }) + .sum::() + } +} + /// One validated v1 declarative node. #[derive(Debug, Clone, PartialEq, Eq)] pub enum UiNode { @@ -368,6 +408,9 @@ impl UiNode { } /// Text bytes in the subtree (`SCN-3`). + /// + /// This stays the per-component text measure; the per-generation aggregate + /// charges [`Self::retained_string_bytes`] instead. #[must_use] pub fn text_bytes(&self) -> usize { match self { @@ -378,6 +421,57 @@ impl UiNode { } } + /// Bytes of every retained string in the subtree (`SCN-4` local subset). + /// + /// Counts `Text` content, `fg`/`bg` theme tokens, command identifiers, and + /// click/scroll argument keys plus string argument values. This is the + /// accounting unit for the per-generation 2 MiB aggregate: identifiers are + /// retained host-side exactly like text, so charging only text would leave + /// a budget hole (CTX-1340). + #[must_use] + pub fn retained_string_bytes(&self) -> usize { + match self { + Self::Text { + text, + fg, + bg, + on_click, + on_scroll, + .. + } => text.len() + retained_style_bytes(fg, bg, on_click, on_scroll), + Self::Row { + children, + fg, + bg, + on_click, + on_scroll, + .. + } + | Self::Column { + children, + fg, + bg, + on_click, + on_scroll, + .. + } + | Self::List { + children, + fg, + bg, + on_click, + on_scroll, + .. + } => { + children + .iter() + .map(Self::retained_string_bytes) + .sum::() + + retained_style_bytes(fg, bg, on_click, on_scroll) + } + } + } + /// Validate an already-marshalled bounded value against the v1 contract. /// /// # Errors @@ -515,6 +609,23 @@ impl UiNode { } } +/// Bytes of the strings retained by one node's style and bindings (CTX-1340). +fn retained_style_bytes( + fg: &Option, + bg: &Option, + on_click: &Option, + on_scroll: &Option, +) -> usize { + fg.as_ref().map_or(0, String::len) + + bg.as_ref().map_or(0, String::len) + + on_click + .as_ref() + .map_or(0, ClickCommand::retained_string_bytes) + + on_scroll + .as_ref() + .map_or(0, ClickCommand::retained_string_bytes) +} + /// Whether `slot` is a member of the accepted closed slot set. #[must_use] pub fn is_ui_slot(slot: &str) -> bool { @@ -632,7 +743,14 @@ fn extract_command_binding( let mut result = Vec::with_capacity(pairs.len()); for (arg_key, val) in pairs { let key_str = match arg_key { - LuaValue::String(s) => s.clone(), + LuaValue::String(s) => { + if s.len() > UI_CLICK_MAX_ARG_KEY_BYTES { + return Err(component_invalid(format!( + "{key}.args keys must be at most {UI_CLICK_MAX_ARG_KEY_BYTES} bytes", + ))); + } + s.clone() + } _ => { return Err(component_invalid(format!( "{key}.args keys must be strings" @@ -753,6 +871,93 @@ mod tests { assert_eq!(node.count_nodes(), 6); assert_eq!(node.depth(), 3); assert_eq!(node.text_bytes(), 6); + assert_eq!(node.retained_string_bytes(), 6); + } + + #[test] + fn retained_string_bytes_charges_identifiers_not_only_text() { + let value = LuaValue::table([ + ("kind", LuaValue::String("Column".to_string())), + ("fg", LuaValue::String("accent".to_string())), + ( + "on_click", + LuaValue::table([ + ("command", LuaValue::String("workspace.switch".to_string())), + ( + "args", + LuaValue::table([ + ("id", LuaValue::String("dev".to_string())), + ("focus", LuaValue::Bool(true)), + ("index", LuaValue::Integer(2)), + ]), + ), + ]), + ), + ( + "children", + LuaValue::array(vec![LuaValue::table([ + ("kind", LuaValue::String("Text".to_string())), + ("text", LuaValue::String("body".to_string())), + ("bg", LuaValue::String("surface.1".to_string())), + ( + "on_scroll", + LuaValue::table([ + ("command", LuaValue::String("bar.scroll".to_string())), + ("args", LuaValue::table([("step", LuaValue::Integer(1))])), + ]), + ), + ])]), + ), + ]); + let node = UiNode::from_lua_value(&value).expect("valid component"); + let expected = "accent".len() + + "workspace.switch".len() + + "id".len() + + "dev".len() + + "focus".len() + + "index".len() + + "body".len() + + "surface.1".len() + + "bar.scroll".len() + + "step".len(); + assert_eq!(node.retained_string_bytes(), expected); + // The SCN-3 per-component measure stays text-only. + assert_eq!(node.text_bytes(), "body".len()); + } + + #[test] + fn click_arg_keys_are_capped_and_charged() { + let with_key = |key: &str| { + LuaValue::table([ + ("kind", LuaValue::String("Text".to_string())), + ("text", LuaValue::String("t".to_string())), + ( + "on_click", + LuaValue::table([ + ("command", LuaValue::String("cmd".to_string())), + ( + "args", + LuaValue::table([(key, LuaValue::String("v".to_string()))]), + ), + ]), + ), + ]) + }; + let at_cap = "k".repeat(UI_CLICK_MAX_ARG_KEY_BYTES); + assert_eq!( + UI_CLICK_MAX_ARG_KEY_BYTES, UI_CLICK_MAX_COMMAND_BYTES, + "the key cap reuses the command-identifier bound; no new ceiling" + ); + let node = UiNode::from_lua_value(&with_key(&at_cap)).expect("64-byte key accepted"); + assert_eq!( + node.retained_string_bytes(), + "t".len() + "cmd".len() + at_cap.len() + "v".len() + ); + + let over = "k".repeat(UI_CLICK_MAX_ARG_KEY_BYTES + 1); + let error = UiNode::from_lua_value(&with_key(&over)).expect_err("65-byte key rejected"); + assert_eq!(error.code, "E_UI_COMPONENT_INVALID"); + assert!(error.message.contains("args keys must be at most")); } #[test] diff --git a/crates/bitty-runtime/src/plugin_runtime/services.rs b/crates/bitty-runtime/src/plugin_runtime/services.rs index b0322d4b..030bb350 100644 --- a/crates/bitty-runtime/src/plugin_runtime/services.rs +++ b/crates/bitty-runtime/src/plugin_runtime/services.rs @@ -774,13 +774,18 @@ impl UiBlock { /// the accepted `SCN-4`/`SCN-5` budgets are terminal-wide (all plugins), and /// the terminal-wide accounting is owned by the host composer. Exceeding a /// registry cap fails closed with `E_UI_BLOCK_BUDGET` (`budget` class), a host -/// diagnostic pending an accepted stable plugin-visible code. +/// diagnostic pending an accepted stable plugin-visible code. The `SCN-4` +/// aggregate charges every retained string in a subtree (text, theme tokens, +/// command identifiers, argument keys, and string argument values), because +/// all of them stay resident in the registry (CTX-1340). #[derive(Debug)] pub struct UiBlocks { blocks: Vec<(i64, UiBlock)>, epoch: u32, next_counter: u32, - aggregated_text_bytes: usize, + /// Retained string bytes across every block (`Text` content, theme tokens, + /// command identifiers, argument keys, and string argument values). + aggregated_retained_bytes: usize, } /// Bits of the handle reserved for the generation epoch. Capped at 31 so every @@ -805,7 +810,7 @@ impl UiBlocks { blocks: Vec::new(), epoch: 0, next_counter: 1, - aggregated_text_bytes: 0, + aggregated_retained_bytes: 0, } } @@ -823,7 +828,7 @@ impl UiBlocks { /// the clear can never alias one minted after it. fn clear(&mut self) { self.blocks.clear(); - self.aggregated_text_bytes = 0; + self.aggregated_retained_bytes = 0; } /// Compose the next generation-owned handle, or fail closed when this @@ -874,18 +879,18 @@ impl UiBlocks { format!("ui block registry is full ({UI_MAX_BLOCKS} blocks)"), )); } - let bytes = node.text_bytes(); - let total = self.aggregated_text_bytes.saturating_add(bytes); + let bytes = node.retained_string_bytes(); + let total = self.aggregated_retained_bytes.saturating_add(bytes); if total > UI_MAX_AGGREGATED_TEXT_BYTES { return Err(BridgeError::new( "budget", "E_UI_BLOCK_BUDGET", - "ui block text budget exceeded (2 MiB aggregated)", + "ui block retained-string budget exceeded (2 MiB aggregated)", )); } let handle = self.next_handle()?; self.next_counter = self.next_counter.saturating_add(1); - self.aggregated_text_bytes = total; + self.aggregated_retained_bytes = total; self.blocks.push(( handle, UiBlock { @@ -906,26 +911,26 @@ impl UiBlocks { else { return Ok(false); }; - let old_bytes = self.blocks[position].1.node.text_bytes(); + let old_bytes = self.blocks[position].1.node.retained_string_bytes(); let total = self - .aggregated_text_bytes + .aggregated_retained_bytes .saturating_sub(old_bytes) - .saturating_add(node.text_bytes()); + .saturating_add(node.retained_string_bytes()); if total > UI_MAX_AGGREGATED_TEXT_BYTES { return Err(BridgeError::new( "budget", "E_UI_BLOCK_BUDGET", - "ui block text budget exceeded (2 MiB aggregated)", + "ui block retained-string budget exceeded (2 MiB aggregated)", )); } - self.aggregated_text_bytes = total; + self.aggregated_retained_bytes = total; let block = &mut self.blocks[position].1; block.node = node; block.version = block.version.saturating_add(1); Ok(true) } - /// Drop one block by handle, releasing its text budget. + /// Drop one block by handle, releasing its retained-string budget. /// /// Used to roll back a spec-acquired surface when capture acquisition /// fails, so a denied acquire leaves no orphan block behind. Returns @@ -940,9 +945,9 @@ impl UiBlocks { return false; }; let (_, block) = self.blocks.remove(position); - self.aggregated_text_bytes = self - .aggregated_text_bytes - .saturating_sub(block.node.text_bytes()); + self.aggregated_retained_bytes = self + .aggregated_retained_bytes + .saturating_sub(block.node.retained_string_bytes()); true } } @@ -2303,7 +2308,7 @@ impl PluginServices { self.spec_overlay_handles.borrow_mut().clear(); } - /// Drop one block by handle, releasing its text budget. + /// Drop one block by handle, releasing its retained-string budget. /// /// Rolls back a spec-acquired surface when capture acquisition fails, so /// a denied acquire leaves no orphan block behind. @@ -4155,7 +4160,9 @@ mod tests { use crate::plugin_runtime::store::PluginStore; use bitty_lua::E_UI_ALREADY_CAPTURED; use bitty_lua::ENV_KEY_MAX_BYTES; - use bitty_lua::ui::UI_MAX_TEXT_BYTES; + use bitty_lua::ui::{ + ClickArg, ClickCommand, UI_CLICK_MAX_ARGS, UI_MAX_NODES, UI_MAX_TEXT_BYTES, + }; use std::time::Duration; fn services() -> PluginServices { @@ -6140,6 +6147,130 @@ mod tests { }); } + /// A node whose retained strings span text, tokens, commands, arg keys, + /// and a string arg value (CTX-1340). + fn retained_string_node(text_len: usize) -> UiNode { + UiNode::Text { + text: "x".repeat(text_len), + fg: Some("accent".to_string()), + bg: Some("surface.1".to_string()), + bold: None, + on_click: Some(ClickCommand { + command: "workspace.switch".to_string(), + args: vec![ + ("id".to_string(), ClickArg::String("dev".to_string())), + ("focus".to_string(), ClickArg::Bool(true)), + ], + }), + on_scroll: Some(ClickCommand { + command: "bar.scroll".to_string(), + args: vec![("step".to_string(), ClickArg::Number(1.0))], + }), + } + } + + #[test] + fn aggregated_retained_budget_counts_every_string() { + let overhead = retained_string_node(0).retained_string_bytes(); + assert!( + overhead < UI_MAX_TEXT_BYTES, + "the mixed-string overhead must leave room for text" + ); + let node = retained_string_node(UI_MAX_TEXT_BYTES - overhead); + assert_eq!( + node.retained_string_bytes(), + UI_MAX_TEXT_BYTES, + "text + tokens + identifiers must fill exactly one 256 KiB slice" + ); + assert!( + node.text_bytes() < node.retained_string_bytes(), + "the aggregate must charge more than the text-only measure" + ); + + let services = ui_services(rich_only()); + for _ in 0..(UI_MAX_AGGREGATED_TEXT_BYTES / UI_MAX_TEXT_BYTES) { + services + .ui_mount("bottom", &node) + .expect("exactly at the retained-string aggregate cap"); + } + let error = services + .ui_mount("bottom", &UiNode::text("x")) + .expect_err("one byte over the aggregate cap must fail closed"); + assert_eq!(error.code, "E_UI_BLOCK_BUDGET"); + assert_eq!(error.class, "budget"); + services.with_ui_blocks(|blocks| { + assert_eq!( + blocks.len(), + UI_MAX_AGGREGATED_TEXT_BYTES / UI_MAX_TEXT_BYTES + ) + }); + } + + /// A node with no text whose click argument keys alone exceed 2 MiB. + /// + /// 2047 leaf nodes (the `Row` makes the subtree exactly `UI_MAX_NODES`) + /// each carry one command with 16 distinct 64-byte keys and no string + /// values, so text bytes stay zero and only keys can drive the budget. + fn large_key_node() -> UiNode { + let args = (0..UI_CLICK_MAX_ARGS) + .map(|index| (format!("{index:064}"), ClickArg::Bool(true))) + .collect::>(); + let child = UiNode::Text { + text: String::new(), + fg: None, + bg: None, + bold: None, + on_click: Some(ClickCommand { + command: "c".to_string(), + args, + }), + on_scroll: None, + }; + UiNode::Row { + children: (0..(UI_MAX_NODES - 1)).map(|_| child.clone()).collect(), + fg: None, + bg: None, + bold: None, + on_click: None, + on_scroll: None, + } + } + + #[test] + fn large_click_arg_keys_are_charged_and_fail_closed() { + let payload = large_key_node(); + assert_eq!(payload.text_bytes(), 0, "the payload carries no text"); + assert!( + payload.retained_string_bytes() > UI_MAX_AGGREGATED_TEXT_BYTES, + "argument keys alone must exceed the aggregate" + ); + + let services = ui_services(rich_only()); + let handle = services + .ui_mount("bottom", &UiNode::text("seed")) + .expect("seed mount"); + let update = services + .ui_update(handle, &payload) + .expect_err("key-heavy update must fail closed"); + assert_eq!(update.code, "E_UI_BLOCK_BUDGET"); + assert_eq!(update.class, "budget"); + services.with_ui_blocks(|blocks| { + assert_eq!( + blocks.get(handle).expect("seed retained").node(), + &UiNode::text("seed"), + "a rejected update must leave the old block intact" + ); + }); + + let mount = services + .ui_mount("bottom", &payload) + .expect_err("key-heavy mount must fail closed"); + assert_eq!(mount.code, "E_UI_BLOCK_BUDGET"); + services.with_ui_blocks(|blocks| { + assert_eq!(blocks.len(), 1, "a rejected mount must not be retained"); + }); + } + /// Backing head shared by the service unit-test fixtures. const TEST_HEAD: &str = "runtime.inspect"; diff --git a/crates/bitty-runtime/tests/plugin_ui.rs b/crates/bitty-runtime/tests/plugin_ui.rs index a15e3a7d..bf9e886d 100644 --- a/crates/bitty-runtime/tests/plugin_ui.rs +++ b/crates/bitty-runtime/tests/plugin_ui.rs @@ -650,6 +650,62 @@ fn oversized_component_is_rejected_before_any_mount() { .with_ui_blocks(|blocks| assert!(blocks.is_empty())); } +#[test] +fn click_arg_keys_are_capped_at_the_bridge() { + // CTX-1340: argument keys are identifiers on the command surface and get + // the same 64-byte bound as the command identifier. The at-cap key is + // accepted; one byte more fails closed before anything is mounted. + let fixture = Fixture::activate( + "argkey", + "bitty-featured.uiargkey", + &["ui.rich"], + &[], + r#" + local at_cap = string.rep("k", 64) + local ok, handle = pcall(bitty.ui.mount, "bottom", { + kind = "Text", + text = "t", + on_click = { command = "cmd", args = { [at_cap] = "v" } }, + }) + bitty.store.set("ok", ok) + bitty.store.set("handle", ok and handle or -1) + + local over = string.rep("k", 65) + local over_ok, err = pcall(bitty.ui.mount, "bottom", { + kind = "Text", + text = "t", + on_click = { command = "cmd", args = { [over] = "v" } }, + }) + bitty.store.set("over_ok", over_ok) + bitty.store.set("over_code", over_ok and "NONE" or err.code) + return {} + "#, + ); + + assert_eq!( + store_value(&fixture.runtime, &fixture.id, "ok"), + Some(LuaValue::Bool(true)), + "a 64-byte argument key is at the cap and must mount" + ); + match store_value(&fixture.runtime, &fixture.id, "handle") { + Some(LuaValue::Integer(handle)) => assert!(handle > 0), + other => panic!("at-cap mount must return a handle, got {other:?}"), + } + assert_eq!( + store_value(&fixture.runtime, &fixture.id, "over_ok"), + Some(LuaValue::Bool(false)) + ); + assert_eq!( + store_value(&fixture.runtime, &fixture.id, "over_code"), + Some(LuaValue::String("E_UI_COMPONENT_INVALID".to_string())) + ); + fixture + .runtime + .services(&fixture.id) + .expect("services") + .with_ui_blocks(|blocks| assert_eq!(blocks.len(), 1)); +} + #[test] fn reload_starts_the_next_generation_with_a_fresh_registry() { let mut fixture = Fixture::activate_with_commands(