From 16e10eaefe62f5ec2d62ae4ad9d1365fcb211e70 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Fri, 12 Jun 2026 22:32:34 -0500 Subject: [PATCH 01/24] Flow wiped + some ui cleanups --- Cargo.lock | 5 - crates/flowstate-flow/Cargo.toml | 10 - crates/flowstate-flow/src/actions.rs | 411 ----- crates/flowstate-flow/src/document.rs | 273 +--- crates/flowstate-flow/src/history.rs | 132 -- crates/flowstate-flow/src/lib.rs | 24 +- crates/flowstate-flow/src/persistence.rs | 203 +-- crates/flowstate-flow/src/styles.rs | 395 ----- crates/flowstate/Cargo.toml | 3 - crates/flowstate/src/flow/editor.rs | 1454 +---------------- crates/flowstate/src/flow/panel.rs | 26 +- crates/flowstate/src/flow/ribbon.rs | 442 +---- .../flowstate/src/workspace/toolkit_panel.rs | 6 +- .../src/workspace/workspace/render_outline.rs | 165 +- 14 files changed, 50 insertions(+), 3499 deletions(-) delete mode 100644 crates/flowstate-flow/src/actions.rs delete mode 100644 crates/flowstate-flow/src/history.rs delete mode 100644 crates/flowstate-flow/src/styles.rs diff --git a/Cargo.lock b/Cargo.lock index 311e03d5..247ab1eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2586,11 +2586,6 @@ name = "flowstate-flow" version = "0.1.0" dependencies = [ "anyhow", - "hotpath", - "rustc-hash 2.1.2", - "serde", - "serde_json", - "uuid", ] [[package]] diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index 1333a38f..ddf759a6 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -9,13 +9,3 @@ workspace = true [dependencies] anyhow = { workspace = true } -hotpath = { workspace = true } -rustc-hash = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } - -[features] -hotpath = ["hotpath/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"] diff --git a/crates/flowstate-flow/src/actions.rs b/crates/flowstate-flow/src/actions.rs deleted file mode 100644 index c05add08..00000000 --- a/crates/flowstate-flow/src/actions.rs +++ /dev/null @@ -1,411 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::document::{BoxNode, Flow, FlowDocument, Node, NodeId, NodeValue, Nodes, ROOT_ID, constrain_index, new_box_id, new_flow_id}; -use crate::styles::DebateStyleFlow; - -pub type ActionBundle = Vec; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "tag")] -pub enum Action { - #[serde(rename = "add")] - Add { - parent: NodeId, - id: NodeId, - index: usize, - value: NodeValue, - }, - #[serde(rename = "delete")] - Delete { id: NodeId }, - #[serde(rename = "update")] - Update { - id: NodeId, - #[serde(rename = "newValue")] - new_value: NodeValue, - }, - #[serde(rename = "move")] - Move { - id: NodeId, - #[serde(rename = "newIndex")] - new_index: usize, - }, - #[serde(rename = "replace")] - Replace { - #[serde(rename = "newNodes")] - new_nodes: Nodes, - }, - #[serde(rename = "identity")] - Identity, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandResult { - pub actions: ActionBundle, - pub owner: NodeId, - pub focus: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FormatKind { - Bold, - Crossed, -} - -#[hotpath::measure_all] -impl FlowDocument { - pub fn apply_action(&mut self, action: Action) -> Action { - match action { - Action::Add { parent, id, index, value } => { - let Some(parent_node) = self.nodes.get(&parent) else { - return Action::Identity; - }; - let child = Node { - value, - level: parent_node.level + 1, - parent: Some(parent.clone()), - children: Vec::new(), - }; - self.nodes.insert(id.clone(), child); - let Some(parent_node) = self.nodes.get_mut(&parent) else { - return Action::Identity; - }; - let index = constrain_index(index, parent_node.children.len()); - parent_node.children.insert(index, id.clone()); - Action::Delete { id } - }, - Action::Delete { id } => { - let Some(node) = self.nodes.get(&id).cloned() else { - return Action::Identity; - }; - let Some(parent_id) = node.parent.clone() else { - return Action::Identity; - }; - let Some(parent) = self.nodes.get_mut(&parent_id) else { - return Action::Identity; - }; - let Some(index) = parent.children.iter().position(|child| child == &id) else { - return Action::Identity; - }; - parent.children.remove(index); - self.nodes.remove(&id); - Action::Add { - parent: parent_id, - id, - index, - value: node.value, - } - }, - Action::Update { id, new_value } => { - let Some(node) = self.nodes.get_mut(&id) else { - return Action::Identity; - }; - let inverse = Action::Update { - id, - new_value: node.value.clone(), - }; - node.value = new_value; - inverse - }, - Action::Move { id, new_index } => { - let Some(node) = self.nodes.get(&id) else { - return Action::Identity; - }; - let Some(parent_id) = node.parent.clone() else { - return Action::Identity; - }; - let Some(parent) = self.nodes.get_mut(&parent_id) else { - return Action::Identity; - }; - let Some(index) = parent.children.iter().position(|child| child == &id) else { - return Action::Identity; - }; - parent.children.remove(index); - let index_after_remove = constrain_index(new_index, parent.children.len()); - parent.children.insert(index_after_remove, id.clone()); - Action::Move { id, new_index: index } - }, - Action::Replace { new_nodes } => { - let inverse = Action::Replace { - new_nodes: self.nodes.clone(), - }; - self.nodes = new_nodes; - inverse - }, - Action::Identity => Action::Identity, - } - } - - pub fn apply_action_bundle(&mut self, actions: ActionBundle) -> ActionBundle { - let mut inverse = Vec::with_capacity(actions.len()); - for action in actions { - inverse.push(self.apply_action(action)); - } - inverse.reverse(); - inverse - } -} - -#[hotpath::measure] -pub fn new_box_action(parent: NodeId, parent_flow_id: NodeId, index: usize, placeholder: Option) -> Action { - Action::Add { - parent, - id: new_box_id(), - index, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: parent_flow_id, - placeholder, - empty: false, - crossed: false, - bold: false, - is_extension: false, - }), - } -} - -#[hotpath::measure] -pub const fn new_extension_action(parent: NodeId, parent_flow_id: NodeId, id: NodeId) -> Action { - Action::Add { - parent, - id, - index: 0, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: parent_flow_id, - placeholder: None, - empty: false, - crossed: false, - bold: false, - is_extension: true, - }), - } -} - -#[hotpath::measure] -#[must_use] -pub const fn new_update_action(id: NodeId, new_value: NodeValue) -> Action { - Action::Update { id, new_value } -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_box_actions(document: &FlowDocument, parent: NodeId, index: usize, placeholder: Option) -> Option { - let flow_id = document.parent_flow_id(&parent)?; - let action = new_box_action(parent, flow_id.clone(), index, placeholder); - let focus = match &action { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - Some(CommandResult { - actions: vec![action], - owner: flow_id, - focus, - }) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_extension_actions(document: &FlowDocument, parent: NodeId) -> Option { - let flow_id = document.parent_flow_id(&parent)?; - let extension_id = new_box_id(); - let child = new_box_action(extension_id.clone(), flow_id.clone(), 0, None); - let focus = match &child { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - Some(CommandResult { - actions: vec![new_extension_action(parent, flow_id.clone(), extension_id), child], - owner: flow_id, - focus, - }) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_flow_actions(index: usize, style: &DebateStyleFlow, switch_speakers: bool) -> CommandResult { - let starter_boxes = style.starter_boxes.as_deref(); - let columns = if switch_speakers { - style - .columns_switch - .clone() - .unwrap_or_else(|| style.columns.clone()) - } else { - style.columns.clone() - }; - let flow_id = new_flow_id(); - let mut actions = vec![Action::Add { - parent: ROOT_ID.to_owned(), - id: flow_id.clone(), - index, - value: NodeValue::Flow(Flow { - content: String::new(), - invert: style.invert, - columns, - }), - }]; - - if let Some(starter_boxes) = starter_boxes { - for (index, placeholder) in starter_boxes.iter().enumerate() { - actions.push(new_box_action(flow_id.clone(), flow_id.clone(), index, Some((*placeholder).clone()))); - } - } else { - actions.push(new_box_action(flow_id.clone(), flow_id.clone(), 0, None)); - } - - CommandResult { - actions, - owner: ROOT_ID.to_owned(), - focus: Some(flow_id), - } -} - -#[hotpath::measure] -#[must_use] -pub fn toggle_box_format_actions(document: &FlowDocument, id: NodeId, format: FormatKind) -> Option { - let mut box_node = document.box_node(&id)?.clone(); - match format { - FormatKind::Bold => box_node.bold = !box_node.bold, - FormatKind::Crossed => box_node.crossed = !box_node.crossed, - } - let owner = document.parent_flow_id(&id)?; - Some(CommandResult { - actions: vec![new_update_action(id.clone(), NodeValue::Box(box_node))], - owner, - focus: Some(id), - }) -} - -#[hotpath::measure] -#[must_use] -pub fn move_node_actions(document: &FlowDocument, id: NodeId, new_index: usize) -> Option { - let owner = match document.node(&id)?.value { - NodeValue::Flow(_) => ROOT_ID.to_owned(), - NodeValue::Box(_) => document.parent_flow_id(&id)?, - NodeValue::Root => return None, - }; - Some(CommandResult { - actions: vec![Action::Move { id: id.clone(), new_index }], - owner, - focus: Some(id), - }) -} - -#[hotpath::measure] -#[must_use] -pub fn delete_node_actions(document: &FlowDocument, id: NodeId) -> Option { - let owner = match document.node(&id)?.value { - NodeValue::Flow(_) => ROOT_ID.to_owned(), - NodeValue::Box(_) => document.parent_flow_id(&id)?, - NodeValue::Root => return None, - }; - let mut actions = Vec::new(); - collect_delete_actions(document, &id, &mut actions)?; - Some(CommandResult { actions, owner, focus: None }) -} - -#[hotpath::measure] -fn collect_delete_actions(document: &FlowDocument, id: &str, actions: &mut ActionBundle) -> Option<()> { - let node = document.node(id)?; - for child in node.children.iter().rev() { - collect_delete_actions(document, child, actions)?; - } - actions.push(Action::Delete { id: id.to_owned() }); - Some(()) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_empty_actions(document: &FlowDocument, flow_id: NodeId, level: usize) -> Option { - document.flow(&flow_id)?; - let mut actions = Vec::with_capacity(level + 1); - let mut parent_id = flow_id.clone(); - for _ in 0..level { - let id = new_box_id(); - actions.push(Action::Add { - parent: parent_id, - id: id.clone(), - index: 0, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: flow_id.clone(), - placeholder: None, - empty: true, - crossed: false, - bold: false, - is_extension: false, - }), - }); - parent_id = id; - } - let final_box = new_box_action(parent_id, flow_id.clone(), 0, None); - let focus = match &final_box { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - actions.push(final_box); - Some(CommandResult { - actions, - owner: flow_id, - focus, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::history::HistoryHolder; - use crate::styles::{DebateStyleKey, debate_style_templates}; - - #[test] - #[hotpath::measure] - fn add_update_delete_and_undo_round_trip() { - let mut document = FlowDocument::new(); - let template = debate_style_templates(DebateStyleKey::Policy, false).remove(0); - let add_flow = add_new_flow_actions(0, &template, false); - let flow_id = add_flow.focus.clone().unwrap(); - let inverse = document.apply_action_bundle(add_flow.actions); - let mut history = HistoryHolder::new(); - history.add(add_flow.owner, inverse, None, Some(flow_id.clone())); - - let first_box = document.node(&flow_id).unwrap().children[0].clone(); - let mut box_node = document.box_node(&first_box).unwrap().clone(); - box_node.content = "plan flaw".to_string(); - let inverse = document.apply_action_bundle(vec![new_update_action(first_box.clone(), NodeValue::Box(box_node))]); - history.add(flow_id.clone(), inverse, Some(first_box.clone()), Some(first_box.clone())); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - - history.undo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, ""); - - history.redo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - - let delete = delete_node_actions(&document, first_box.clone()).unwrap(); - let inverse = document.apply_action_bundle(delete.actions); - history.add(flow_id.clone(), inverse, Some(first_box.clone()), Some(flow_id.clone())); - assert!(document.box_node(&first_box).is_none()); - - history.undo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - } - - #[test] - #[hotpath::measure] - fn move_flow_actions_reorders_root_children_and_undoes() { - let mut document = FlowDocument::new(); - let template = debate_style_templates(DebateStyleKey::Policy, false).remove(0); - let first = add_new_flow_actions(0, &template, false); - let first_id = first.focus.clone().unwrap(); - document.apply_action_bundle(first.actions); - let second = add_new_flow_actions(1, &template, false); - let second_id = second.focus.clone().unwrap(); - document.apply_action_bundle(second.actions); - - let command = move_node_actions(&document, first_id.clone(), 1).unwrap(); - let inverse = document.apply_action_bundle(command.actions); - assert_eq!(document.flow_ids(), &[second_id.clone(), first_id.clone()]); - - document.apply_action_bundle(inverse); - assert_eq!(document.flow_ids(), &[first_id, second_id]); - } -} diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index f8b3b31e..4db2d1cd 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -1,275 +1,8 @@ -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FlowDocument; -pub const ROOT_ID: &str = "root"; - -pub type NodeId = String; -pub type Nodes = FxHashMap; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Node { - pub value: NodeValue, - pub level: i32, - pub parent: Option, - #[serde(default)] - pub children: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "tag")] -pub enum NodeValue { - #[serde(rename = "root")] - Root, - #[serde(rename = "flow")] - Flow(Flow), - #[serde(rename = "box")] - Box(BoxNode), -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Flow { - #[serde(default)] - pub content: String, - #[serde(default)] - pub invert: bool, - #[serde(default)] - pub columns: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct BoxNode { - #[serde(default)] - pub content: String, - #[serde(rename = "flowId")] - pub flow_id: NodeId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub placeholder: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub empty: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub crossed: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub bold: bool, - #[serde(rename = "isExtension", default, skip_serializing_if = "is_false")] - pub is_extension: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FlowDocument { - pub nodes: Nodes, -} - -#[hotpath::measure_all] -impl Default for FlowDocument { - fn default() -> Self { - Self::new() - } -} - -#[hotpath::measure_all] impl FlowDocument { - #[must_use] pub fn new() -> Self { - let mut nodes = Nodes::default(); - nodes.insert( - ROOT_ID.to_owned(), - Node { - value: NodeValue::Root, - level: -1, - parent: None, - children: Vec::new(), - }, - ); - Self { nodes } + Self } - - #[must_use] - pub fn from_nodes(mut nodes: Nodes) -> Self { - nodes.entry(ROOT_ID.to_owned()).or_insert_with(|| Node { - value: NodeValue::Root, - level: -1, - parent: None, - children: Vec::new(), - }); - Self { nodes } - } - - #[must_use] - pub fn root(&self) -> &Node { - self - .nodes - .get(ROOT_ID) - .expect("FlowDocument invariant violated: root node missing") - } - - pub fn root_mut(&mut self) -> &mut Node { - self - .nodes - .get_mut(ROOT_ID) - .expect("FlowDocument invariant violated: root node missing") - } - - pub fn node(&self, id: impl AsRef) -> Option<&Node> { - self.nodes.get(id.as_ref()) - } - - pub fn node_mut(&mut self, id: impl AsRef) -> Option<&mut Node> { - self.nodes.get_mut(id.as_ref()) - } - - pub fn flow(&self, id: impl AsRef) -> Option<&Flow> { - match &self.node(id)?.value { - NodeValue::Flow(flow) => Some(flow), - _ => None, - } - } - - pub fn flow_mut(&mut self, id: impl AsRef) -> Option<&mut Flow> { - match &mut self.node_mut(id)?.value { - NodeValue::Flow(flow) => Some(flow), - _ => None, - } - } - - pub fn box_node(&self, id: impl AsRef) -> Option<&BoxNode> { - match &self.node(id)?.value { - NodeValue::Box(box_node) => Some(box_node), - _ => None, - } - } - - pub fn box_node_mut(&mut self, id: impl AsRef) -> Option<&mut BoxNode> { - match &mut self.node_mut(id)?.value { - NodeValue::Box(box_node) => Some(box_node), - _ => None, - } - } - - #[must_use] - pub fn flow_ids(&self) -> &[NodeId] { - &self.root().children - } - - pub fn parent_flow_id(&self, id: impl AsRef) -> Option { - let id = id.as_ref(); - match &self.node(id)?.value { - NodeValue::Flow(_) => Some(id.to_owned()), - NodeValue::Box(box_node) => Some(box_node.flow_id.clone()), - NodeValue::Root => None, - } - } - - pub fn check_box_id(&self, id: impl AsRef) -> Option { - let id = id.as_ref(); - self.box_node(id).map(|_| id.to_owned()) - } - - pub fn child_index(&self, parent_id: impl AsRef, child_id: impl AsRef) -> Option { - self - .node(parent_id)? - .children - .iter() - .position(|id| id == child_id.as_ref()) - } - - pub fn adjacent_box(&self, id: impl AsRef, direction: Direction) -> Option { - let id = id.as_ref(); - let node = self.node(id)?; - if !matches!(node.value, NodeValue::Box(_)) { - return None; - } - let parent_id = node.parent.as_ref()?; - let parent = self.node(parent_id)?; - let index = parent.children.iter().position(|child| child == id)?; - let next_index = match direction { - Direction::Up => index.checked_sub(1), - Direction::Down => Some(index + 1).filter(|ix| *ix < parent.children.len()), - }; - if let Some(next_index) = next_index { - return parent.children.get(next_index).cloned(); - } - - let parent_box_id = self.check_box_id(parent_id)?; - let mut adjacent_parent = self.adjacent_box(parent_box_id, direction)?; - loop { - let adjacent_node = self.node(&adjacent_parent)?; - if !adjacent_node.children.is_empty() { - let child_index = match direction { - Direction::Up => adjacent_node.children.len() - 1, - Direction::Down => 0, - }; - return adjacent_node.children.get(child_index).cloned(); - } - adjacent_parent = self.adjacent_box(adjacent_parent, direction)?; - } - } - - #[must_use] - pub fn is_worth_saving(&self, ignore_first_empty_flow: bool) -> bool { - let root_children = &self.root().children; - if root_children.is_empty() { - return false; - } - if root_children.len() != 1 || !ignore_first_empty_flow { - return true; - } - - let Some(flow) = self.node(&root_children[0]) else { - return false; - }; - let NodeValue::Flow(flow_value) = &flow.value else { - return false; - }; - if !flow_value.content.is_empty() { - return true; - } - if flow.children.is_empty() { - return false; - } - if flow.children.len() > 1 { - return true; - } - let Some(first_child) = self.node(&flow.children[0]) else { - return false; - }; - match &first_child.value { - NodeValue::Box(box_node) => !(box_node.content.is_empty() && first_child.children.is_empty()), - _ => true, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Direction { - Up, - Down, -} - -#[hotpath::measure] -pub fn new_node_id() -> NodeId { - Uuid::new_v4().to_string() -} - -#[hotpath::measure] -#[must_use] -pub fn new_box_id() -> NodeId { - new_node_id() -} - -#[hotpath::measure] -#[must_use] -pub fn new_flow_id() -> NodeId { - new_node_id() -} - -#[hotpath::measure] -#[must_use] -pub fn constrain_index(index: usize, len: usize) -> usize { - index.min(len) -} - -#[hotpath::measure] -const fn is_false(value: &bool) -> bool { - !*value } diff --git a/crates/flowstate-flow/src/history.rs b/crates/flowstate-flow/src/history.rs deleted file mode 100644 index b3c2bbb9..00000000 --- a/crates/flowstate-flow/src/history.rs +++ /dev/null @@ -1,132 +0,0 @@ -use rustc_hash::FxHashMap; - -use crate::actions::ActionBundle; -use crate::document::{FlowDocument, NodeId, ROOT_ID}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HistoryAction { - pub before_focus: Option, - pub after_focus: Option, - pub action_bundle: ActionBundle, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct History { - index: Option, - actions: Vec, -} - -#[hotpath::measure_all] -impl History { - pub fn add(&mut self, action_bundle: ActionBundle, before_focus: Option, after_focus: Option) { - let keep = self.index.map_or(0, |index| index + 1); - self.actions.truncate(keep); - self.actions.push(HistoryAction { - before_focus, - after_focus, - action_bundle, - }); - self.index = Some(self.actions.len() - 1); - } - - pub fn undo(&mut self, document: &mut FlowDocument) -> Option> { - let index = self.index?; - let action = self.actions.get_mut(index)?; - let inverse = document.apply_action_bundle(action.action_bundle.clone()); - action.action_bundle = inverse; - self.index = index.checked_sub(1); - Some(action.before_focus.clone()) - } - - pub fn redo(&mut self, document: &mut FlowDocument) -> Option> { - let next_index = self.index.map_or(0, |index| index + 1); - if next_index >= self.actions.len() { - return None; - } - self.index = Some(next_index); - let action = self.actions.get_mut(next_index)?; - let inverse = document.apply_action_bundle(action.action_bundle.clone()); - action.action_bundle = inverse; - Some( - action - .after_focus - .clone() - .or_else(|| action.before_focus.clone()), - ) - } - - #[must_use] - pub const fn can_undo(&self) -> bool { - self.index.is_some() - } - - #[must_use] - pub fn can_redo(&self) -> bool { - self - .index - .map_or(!self.actions.is_empty(), |index| index + 1 < self.actions.len()) - } - - pub fn clear(&mut self) { - self.index = None; - self.actions.clear(); - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct HistoryHolder { - histories: FxHashMap, - last_added_owner: Option, -} - -#[hotpath::measure_all] -impl HistoryHolder { - #[must_use] - pub fn new() -> Self { - let mut histories = FxHashMap::default(); - histories.insert(ROOT_ID.to_owned(), History::default()); - Self { - histories, - last_added_owner: None, - } - } - - pub fn add(&mut self, owner: NodeId, action_bundle: ActionBundle, before_focus: Option, after_focus: Option) { - self - .histories - .entry(owner.clone()) - .or_default() - .add(action_bundle, before_focus, after_focus); - self.last_added_owner = Some(owner); - } - - pub fn undo(&mut self, owner: impl AsRef, document: &mut FlowDocument) -> Option> { - self.histories.get_mut(owner.as_ref())?.undo(document) - } - - pub fn redo(&mut self, owner: impl AsRef, document: &mut FlowDocument) -> Option> { - self.histories.get_mut(owner.as_ref())?.redo(document) - } - - pub fn can_undo(&self, owner: impl AsRef) -> bool { - self - .histories - .get(owner.as_ref()) - .is_some_and(History::can_undo) - } - - pub fn can_redo(&self, owner: impl AsRef) -> bool { - self - .histories - .get(owner.as_ref()) - .is_some_and(History::can_redo) - } - - pub fn clear(&mut self) { - self.histories.clear(); - self - .histories - .insert(ROOT_ID.to_owned(), History::default()); - self.last_added_owner = None; - } -} diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index 3c065dca..3f908663 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -1,25 +1,5 @@ -//! Native Flowstate model for competitive-debate flow documents. -//! -//! This crate ports the core data and mutation model from `debate-flow` into -//! Rust. It intentionally contains no GPUI code so `.fl0` documents can be -//! loaded, saved, tested, and transformed independently from the desktop UI. - -mod actions; mod document; -mod history; mod persistence; -mod styles; -pub use actions::{ - Action, ActionBundle, CommandResult, FormatKind, add_new_box_actions, add_new_empty_actions, add_new_extension_actions, add_new_flow_actions, - delete_node_actions, move_node_actions, new_update_action, toggle_box_format_actions, -}; -pub use document::{BoxNode, Flow, FlowDocument, Node, NodeId, NodeValue, Nodes, ROOT_ID, constrain_index, new_box_id, new_flow_id}; -pub use history::{History, HistoryAction, HistoryHolder}; -pub use persistence::{ - CURRENT_SAVE_VERSION, SaveableFlowDocument, get_json, load_flow_document, load_flow_document_or_new, load_nodes, save_flow_document, -}; -pub use styles::{ - DebateStyle, DebateStyleFlow, DebateStyleKey, DebateStyleTemplate, TimerSpeech, all_debate_style_templates, debate_style, debate_style_label, - debate_style_templates, -}; +pub use document::FlowDocument; +pub use persistence::{load_flow_document, load_flow_document_or_new, save_flow_document}; diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index 339e9093..e6447b8c 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -1,202 +1,21 @@ use std::fs; use std::path::Path; -use anyhow::{Context as _, Result, bail}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use crate::document::FlowDocument; -use crate::actions::{Action, new_box_action}; -use crate::document::{BoxNode, Flow, FlowDocument, NodeId, NodeValue, Nodes, ROOT_ID, new_box_id, new_flow_id}; - -pub const CURRENT_SAVE_VERSION: u32 = 1; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct SaveableFlowDocument { - pub nodes: Nodes, - pub version: u32, -} - -#[hotpath::measure] -pub fn get_json(document: &FlowDocument) -> Result { - let saveable = SaveableFlowDocument { - nodes: document.nodes.clone(), - version: CURRENT_SAVE_VERSION, - }; - serde_json::to_string(&saveable).context("failed to serialize .fl0 document") -} - -#[hotpath::measure] -pub fn load_nodes(data: Value) -> Result { - let version = data - .get("version") - .and_then(Value::as_u64) - .and_then(|version| u32::try_from(version).ok()) - .unwrap_or(0); - if version == CURRENT_SAVE_VERSION { - let saveable: SaveableFlowDocument = serde_json::from_value(data).context("invalid .fl0 document")?; - return Ok(saveable.nodes); - } - if version == 0 { - return upgrade_0_1(data).map(|saveable| saveable.nodes); - } - bail!("unsupported .fl0 save version {version}"); -} - -#[hotpath::measure] -pub fn load_flow_document(path: impl AsRef) -> Result { - let path = path.as_ref(); - let text = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; - let value: Value = serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?; - let nodes = load_nodes(value)?; - Ok(FlowDocument::from_nodes(nodes)) -} - -#[hotpath::measure] -pub fn load_flow_document_or_new(path: impl AsRef) -> FlowDocument { - load_flow_document(path).unwrap_or_else(|_| FlowDocument::new()) -} - -#[hotpath::measure] -pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> Result<()> { - let path = path.as_ref(); - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; - } - let text = get_json(document)?; - fs::write(path, text).with_context(|| format!("failed to write {}", path.display())) -} - -#[derive(Clone, Debug, Deserialize)] -struct OldFlow { - #[serde(default)] - content: String, - #[serde(default)] - columns: Vec, - #[serde(default)] - invert: bool, - #[serde(default)] - children: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -struct OldBox { - #[serde(default)] - content: String, - #[serde(default)] - children: Vec, - #[serde(default)] - empty: bool, - #[serde(default)] - placeholder: Option, - #[serde(default)] - crossed: bool, -} - -#[hotpath::measure] -fn upgrade_0_1(saved: Value) -> Result { - let old_flows: Vec = serde_json::from_value(saved).context("invalid legacy debate-flow document")?; - let mut document = FlowDocument::new(); - for (index, old_flow) in old_flows.into_iter().enumerate() { - let flow_id = new_flow_id(); - let add_flow = Action::Add { - parent: ROOT_ID.to_owned(), - id: flow_id.clone(), - index, - value: NodeValue::Flow(Flow { - content: old_flow.content, - invert: old_flow.invert, - columns: old_flow.columns, - }), - }; - document.apply_action(add_flow); - for (box_index, old_box) in old_flow.children.into_iter().enumerate() { - upgrade_0_1_add_boxes_rec(&mut document, flow_id.clone(), flow_id.clone(), old_box, box_index); - } - } - Ok(SaveableFlowDocument { - nodes: document.nodes, - version: CURRENT_SAVE_VERSION, - }) -} - -#[hotpath::measure] -fn upgrade_0_1_add_boxes_rec(document: &mut FlowDocument, flow_id: NodeId, parent_id: NodeId, old_box: OldBox, index: usize) { - let id = new_box_id(); - let add = Action::Add { - parent: parent_id, - id: id.clone(), - index, - value: NodeValue::Box(BoxNode { - content: old_box.content, - flow_id: flow_id.clone(), - placeholder: old_box.placeholder, - empty: old_box.empty, - crossed: old_box.crossed, - bold: false, - is_extension: false, - }), - }; - document.apply_action(add); - for (child_index, child) in old_box.children.into_iter().enumerate() { - upgrade_0_1_add_boxes_rec(document, flow_id.clone(), id.clone(), child, child_index); - } +pub fn load_flow_document(_path: impl AsRef) -> anyhow::Result { + Ok(FlowDocument::new()) } -#[allow(dead_code, reason = "Legacy save migration helper is retained for older persisted flow documents.")] -#[hotpath::measure] -fn _new_empty_box(parent_id: NodeId, flow_id: NodeId, index: usize) -> Action { - new_box_action(parent_id, flow_id, index, None) +pub fn load_flow_document_or_new(_path: impl AsRef) -> FlowDocument { + FlowDocument::new() } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[hotpath::measure] - fn loads_current_save_shape() { - let document = FlowDocument::new(); - let json = get_json(&document).unwrap(); - let value: Value = serde_json::from_str(&json).unwrap(); - let nodes = load_nodes(value).unwrap(); - assert!(nodes.contains_key(ROOT_ID)); - } - - #[test] - #[hotpath::measure] - fn upgrades_legacy_flow_array() { - let legacy = serde_json::json!([ - { - "content": "aff", - "level": 0, - "columns": ["1AC", "1NC"], - "invert": false, - "focus": false, - "index": 0, - "lastFocus": [], - "children": [ - { - "content": "advantage", - "children": [], - "index": 0, - "level": 1, - "focus": false, - "placeholder": "type here", - "crossed": true - } - ], - "history": { "index": -1, "data": [], "lastFocus": null }, - "id": 1 - } - ]); - let document = FlowDocument::from_nodes(load_nodes(legacy).unwrap()); - let flow_id = document.flow_ids()[0].clone(); - let box_id = document.node(&flow_id).unwrap().children[0].clone(); - assert_eq!(document.flow(&flow_id).unwrap().content, "aff"); - assert_eq!(document.box_node(&box_id).unwrap().content, "advantage"); - assert!(document.box_node(&box_id).unwrap().crossed); +pub fn save_flow_document(_path: impl AsRef, _document: &FlowDocument) -> anyhow::Result<()> { + let path = _path.as_ref(); + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) { + fs::create_dir_all(parent)?; } + fs::write(path, b"{}")?; + Ok(()) } diff --git a/crates/flowstate-flow/src/styles.rs b/crates/flowstate-flow/src/styles.rs deleted file mode 100644 index ee2d2057..00000000 --- a/crates/flowstate-flow/src/styles.rs +++ /dev/null @@ -1,395 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DebateStyleKey { - Policy, - PublicForum, - LincolnDouglas, - Congress, - WorldSchools, - BigQuestions, - NofSpar, - Parli, - Classic, - BritishParliamentary, - Ippf, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyleTemplate { - pub key: DebateStyleKey, - pub label: &'static str, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyleFlow { - pub name: String, - pub columns: Vec, - pub columns_switch: Option>, - pub invert: bool, - pub starter_boxes: Option>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TimerSpeech { - pub name: String, - pub time_ms: u32, - pub secondary: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyle { - pub flows: Vec, - pub alternative_flows_1: Option>, - pub timer_speeches: Vec, - pub prep_time_ms: Option, -} - -#[hotpath::measure] -#[must_use] -pub fn all_debate_style_templates() -> Vec { - vec![ - DebateStyleTemplate { - key: DebateStyleKey::Policy, - label: "Policy", - }, - DebateStyleTemplate { - key: DebateStyleKey::PublicForum, - label: "Public Forum", - }, - DebateStyleTemplate { - key: DebateStyleKey::LincolnDouglas, - label: "Lincoln Douglas", - }, - DebateStyleTemplate { - key: DebateStyleKey::Congress, - label: "Congress", - }, - DebateStyleTemplate { - key: DebateStyleKey::WorldSchools, - label: "World Schools", - }, - DebateStyleTemplate { - key: DebateStyleKey::BigQuestions, - label: "Big Questions", - }, - DebateStyleTemplate { - key: DebateStyleKey::NofSpar, - label: "NOF SPAR", - }, - DebateStyleTemplate { - key: DebateStyleKey::Parli, - label: "Parli", - }, - DebateStyleTemplate { - key: DebateStyleKey::Classic, - label: "Classic", - }, - DebateStyleTemplate { - key: DebateStyleKey::BritishParliamentary, - label: "British Parliamentary", - }, - DebateStyleTemplate { - key: DebateStyleKey::Ippf, - label: "International Public Policy Forum", - }, - ] -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style_label(key: DebateStyleKey) -> &'static str { - all_debate_style_templates() - .into_iter() - .find(|template| template.key == key) - .map_or("Policy", |template| template.label) -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style_templates(key: DebateStyleKey, ld_toc_circuit: bool) -> Vec { - let style = debate_style(key); - if key == DebateStyleKey::LincolnDouglas && ld_toc_circuit { - style.alternative_flows_1.unwrap_or(style.flows) - } else { - style.flows - } -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style(key: DebateStyleKey) -> DebateStyle { - match key { - DebateStyleKey::Policy => DebateStyle { - flows: vec![ - flow("aff", &["1AC", "1NC", "2AC", "2NC/1NR", "1AR", "2NR", "2AR"], false), - flow("neg", &["1NC", "2AC", "2NC/1NR", "1AR", "2NR", "2AR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("1AC", 8 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("1NC", 8 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, true), - ("2AC", 8 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("2NC", 8 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, true), - ("1NR", 5 * 60 * 1000, true), - ("1AR", 5 * 60 * 1000, false), - ("2NR", 5 * 60 * 1000, true), - ("2AR", 5 * 60 * 1000, false), - ]), - prep_time_ms: Some(8 * 60 * 1000), - }, - DebateStyleKey::PublicForum => DebateStyle { - flows: vec![ - flow_switch( - "aff", - &["AC", "NC", "AR", "NR", "AS", "NS", "AFF", "NFF"], - &["AC", "NR", "AR", "NS", "AS", "NFF", "AFF"], - false, - ), - flow_switch( - "neg", - &["NC", "AR", "NR", "AS", "NS", "AFF", "NFF"], - &["NC", "AC", "NR", "AR", "NS", "AS", "NFF", "AFF"], - true, - ), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 4 * 60 * 1000, false), - ("NC", 4 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("AR", 4 * 60 * 1000, false), - ("NR", 4 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("AS", 3 * 60 * 1000, false), - ("NS", 3 * 60 * 1000, true), - ("GCX", 3 * 60 * 1000, false), - ("AFF", 2 * 60 * 1000, false), - ("NFF", 2 * 60 * 1000, true), - ]), - prep_time_ms: Some(3 * 60 * 1000), - }, - DebateStyleKey::LincolnDouglas => DebateStyle { - flows: vec![ - flow_starter("aff", &["AC", "NR", "1AR", "2NR", "2AR"], false, &["Value", "Criterion"]), - flow_starter("neg", &["NC", "1AR", "2NR", "2AR"], true, &["Value", "Criterion"]), - ], - alternative_flows_1: Some(vec![ - flow_starter("aff", &["AC", "NR", "1AR", "2NR", "2AR"], false, &["type here"]), - flow_starter("neg", &["NC", "1AR", "2NR", "2AR"], true, &["type here"]), - flow_starter("1ar", &["1AR", "2NR", "2AR"], false, &["type here"]), - flow_starter("2nr", &["2NR", "2AR"], true, &["type here"]), - ]), - timer_speeches: speeches(&[ - ("AC", 6 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("NC", 7 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("1AR", 4 * 60 * 1000, false), - ("NR", 6 * 60 * 1000, true), - ("2AR", 3 * 60 * 1000, false), - ]), - prep_time_ms: Some(4 * 60 * 1000), - }, - DebateStyleKey::Congress => DebateStyle { - flows: vec![flow( - "bill", - &[ - "1A", "Q/1N", "Q/2A", "Q/2N", "Q/3A", "Q/3N", "Q/4A", "Q/4N", "Q/5A", "Q/5N", "Q/6A", "Q/6N", "Q/7A", "Q/7N", "Q/8A", "Q/8N", "Q/9A", - "Q/9N", "Q/10A", "Q/10N", "Q/11A", "Q/11N", "Q/12A", "Q/12N", "Q/13A", "Q/13N", "Q/14A", "Q/14N", "Q/15A", "Q/15N", "Q/16A", "Q/16N", - "Q/17A", "Q/17N", "Q/18A", "Q/18N", "Q/19A", "Q/19N", "Q/20A", "Q/20N", "Q/20A", "Q/20N", "Q/21A", "Q/21N", "Q/22A", "Q/22N", "Q/23A", - "Q/23N", "Q/24A", "Q/24N", "Q/25A", "Q/25N", - ], - false, - )], - alternative_flows_1: None, - timer_speeches: speeches(&[("speech", 3 * 60 * 1000, false)]), - prep_time_ms: None, - }, - DebateStyleKey::WorldSchools => DebateStyle { - flows: vec![ - flow("prop", &["P1", "O1", "P2", "O2", "PW", "OW", "OR", "PR"], false), - flow("opp", &["O1", "P2", "O2", "PW", "OW", "OR", "PR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("P1", 8 * 60 * 1000, false), - ("O1", 8 * 60 * 1000, true), - ("P2", 8 * 60 * 1000, false), - ("O2", 8 * 60 * 1000, true), - ("PW", 8 * 60 * 1000, false), - ("OW", 8 * 60 * 1000, true), - ("OR", 4 * 60 * 1000, true), - ("PR", 4 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::BigQuestions => DebateStyle { - flows: vec![ - flow("aff", &["AC", "NC", "ARb", "NRb", "A3", "N3", "ARt", "NRt"], false), - flow("neg", &["NC", "ARb", "NRb", "A3", "N3", "ARt", "NRt"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 5 * 60 * 1000, false), - ("NC", 5 * 60 * 1000, true), - ("QS", 3 * 60 * 1000, false), - ("ARb", 4 * 60 * 1000, false), - ("NRb", 4 * 60 * 1000, true), - ("QS", 3 * 60 * 1000, false), - ("A3", 3 * 60 * 1000, false), - ("N3", 3 * 60 * 1000, true), - ("ARt", 3 * 60 * 1000, false), - ("NRt", 3 * 60 * 1000, true), - ]), - prep_time_ms: Some(3 * 60 * 1000), - }, - DebateStyleKey::NofSpar => DebateStyle { - flows: vec![flow("pro", &["PC", "CC", "PR", "CR"], false), flow("con", &["CC", "PR", "CR"], true)], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("PREP", 2 * 60 * 1000, false), - ("PC", 2 * 60 * 1000, false), - ("CC", 2 * 60 * 1000, true), - ("CX", 4 * 60 * 1000, false), - ("PR", 2 * 60 * 1000, false), - ("CR", 2 * 60 * 1000, true), - ]), - prep_time_ms: None, - }, - DebateStyleKey::Parli => DebateStyle { - flows: vec![ - flow("pro", &["1PC", "1OC", "2PC", "2OC/OR", "PR"], false), - flow("opp", &["1OC", "2PC", "2OC/OR", "PR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("1PC", 7 * 60 * 1000, false), - ("1OC", 8 * 60 * 1000, true), - ("2PC", 8 * 60 * 1000, false), - ("2OC", 8 * 60 * 1000, true), - ("OR", 4 * 60 * 1000, true), - ("PR", 5 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::Classic => DebateStyle { - flows: vec![ - flow("aff", &["AC", "NC/1NR", "1AR", "2NR", "2AR", "NS", "AS"], false), - flow("neg", &["NC/1NR", "1AR", "2NR", "2AR", "AS", "NS"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 6 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, true), - ("NC", 6 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("1NR", 5 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("prep", 2 * 60 * 1000, false), - ("1AR", 7 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, true), - ("2NR", 6 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, false), - ("2AR", 4 * 60 * 1000, false), - ("prep", 2 * 60 * 1000, true), - ("NS", 3 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, false), - ("AS", 3 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::BritishParliamentary => DebateStyle { - flows: vec![ - flow("OG", &["PM", "LO", "DPM", "DLO", "MG", "MO", "GW", "CW"], false), - flow("OO", &["LO", "DPM", "DLO", "MG", "MO", "GW", "CW"], true), - flow("CG", &["MG", "MO", "GW", "CW"], false), - flow("CO", &["MO", "GW", "CW"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("PM", 7 * 60 * 1000, false), - ("LO", 7 * 60 * 1000, true), - ("DPM", 7 * 60 * 1000, false), - ("DLO", 7 * 60 * 1000, true), - ("MG", 7 * 60 * 1000, false), - ("MO", 7 * 60 * 1000, true), - ("GW", 7 * 60 * 1000, false), - ("CW", 7 * 60 * 1000, true), - ]), - prep_time_ms: Some(15 * 1000), - }, - DebateStyleKey::Ippf => DebateStyle { - flows: vec![ - flow("aff", &["A1", "N1", "A2", "N2", "AR", "NR"], false), - flow("neg", &["N1", "A2", "N2", "AR", "NR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("A1", 5 * 60 * 1000, false), - ("N1", 5 * 60 * 1000, true), - ("BREAK", 90 * 1000, false), - ("A2", 5 * 60 * 1000, false), - ("N2", 5 * 60 * 1000, true), - ("ACX", 60 * 1000, false), - ("NCX", 60 * 1000, true), - ("AR", 5 * 60 * 1000, false), - ("NR", 5 * 60 * 1000, true), - ]), - prep_time_ms: None, - }, - } -} - -#[hotpath::measure] -fn flow(name: &str, columns: &[&str], invert: bool) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: None, - invert, - starter_boxes: None, - } -} - -#[hotpath::measure] -fn flow_switch(name: &str, columns: &[&str], columns_switch: &[&str], invert: bool) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: Some(strings(columns_switch)), - invert, - starter_boxes: None, - } -} - -#[hotpath::measure] -fn flow_starter(name: &str, columns: &[&str], invert: bool, starter_boxes: &[&str]) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: None, - invert, - starter_boxes: Some(strings(starter_boxes)), - } -} - -#[hotpath::measure] -fn strings(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).to_owned()).collect() -} - -#[hotpath::measure] -fn speeches(values: &[(&str, u32, bool)]) -> Vec { - values - .iter() - .map(|(name, time_ms, secondary)| TimerSpeech { - name: (*name).to_owned(), - time_ms: *time_ms, - secondary: *secondary, - }) - .collect() -} diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 7368610c..f1c8b327 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -35,21 +35,18 @@ hotpath = [ "hotpath/hotpath", "flowstate-document/hotpath", "flowstate-docx/hotpath", - "flowstate-flow/hotpath", ] hotpath-cpu = [ "hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "flowstate-docx/hotpath-cpu", - "flowstate-flow/hotpath-cpu", ] hotpath-alloc = [ "hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "flowstate-docx/hotpath-alloc", - "flowstate-flow/hotpath-alloc", ] profiler = ["hotpath", "hotpath-alloc"] diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index f2543520..0c6f208c 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,111 +1,28 @@ -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, - path::PathBuf, -}; +use std::path::PathBuf; -use flowstate_flow::{ - Action, BoxNode, CommandResult, DebateStyleFlow, DebateStyleKey, FlowDocument, FormatKind, HistoryHolder, Node, NodeId, NodeValue, ROOT_ID, - add_new_box_actions, add_new_empty_actions, add_new_extension_actions, add_new_flow_actions, all_debate_style_templates, - debate_style_templates, delete_node_actions, get_json, load_flow_document_or_new, move_node_actions, new_box_id, save_flow_document, - toggle_box_format_actions, -}; -use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, - ParentElement, Render, Subscription, Task, Window, div, prelude::*, px, -}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::scroll::ScrollableElement; -use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable, h_flex, v_flex}; -use rustc_hash::{FxHashMap, FxHashSet}; - -use crate::rich_text_element::ToolkitTextDrag; - -const COLUMN_WIDTH: f32 = 172.0; +use gpui::{App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Task, Window, div, prelude::*}; pub struct FlowEditor { - document: FlowDocument, path: Option, dirty: bool, - history: HistoryHolder, focus_handle: FocusHandle, - selected_style: DebateStyleKey, - ld_toc_circuit: bool, - switch_speakers: bool, - selected_flow_id: Option, - focus_id: Option, - last_focus_ids: FxHashMap, - folded: FxHashSet, - box_inputs: FxHashMap>, - input_subscriptions: Vec, - syncing_inputs: FxHashSet, - pending_edit: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FlowOutlineItem { - pub id: NodeId, - pub label: String, - pub index: usize, - pub selected: bool, - pub invert: bool, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FlowCommandState { - pub has_flow: bool, - pub has_selected_box: bool, - pub can_format: bool, - pub can_fold: bool, - pub selected_bold: bool, - pub selected_crossed: bool, - pub selected_folded: bool, - pub can_undo: bool, - pub can_redo: bool, -} - -#[derive(Clone, Debug)] -struct PendingEdit { - id: NodeId, - owner: NodeId, - before_focus: Option, - after_focus: Option, - old_value: NodeValue, -} - -#[hotpath::measure_all] impl FlowEditor { - pub fn new_with_path(document: FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { - let selected_flow_id = document.flow_ids().first().cloned(); - let focus_id = selected_flow_id.clone(); - + pub fn new_with_path(_document: flowstate_flow::FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { Self { - document, path, dirty: false, - history: HistoryHolder::new(), focus_handle: cx.focus_handle(), - selected_style: DebateStyleKey::Policy, - ld_toc_circuit: false, - switch_speakers: false, - selected_flow_id, - focus_id, - last_focus_ids: FxHashMap::default(), - folded: FxHashSet::default(), - box_inputs: FxHashMap::default(), - input_subscriptions: Vec::new(), - syncing_inputs: FxHashSet::default(), - pending_edit: None, } } pub fn blank(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(FlowDocument::new(), None, window, cx) + Self::new_with_path(flowstate_flow::FlowDocument::new(), None, window, cx) } pub fn load_or_new(path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(load_flow_document_or_new(&path), Some(path), window, cx) + Self::new_with_path(flowstate_flow::load_flow_document_or_new(&path), Some(path), window, cx) } pub fn document_path(&self) -> Option<&PathBuf> { @@ -118,41 +35,33 @@ impl FlowEditor { } pub fn has_unsaved_changes(&self) -> bool { - self.dirty || self.pending_edit.is_some() + self.dirty } pub fn save(&mut self, cx: &mut Context) -> Task> { - self.resolve_pending_edit(cx); let Some(path) = self.path.clone() else { - return cx - .background_executor() - .spawn(async { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) }); + return cx.background_executor().spawn(async { + Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) + }); }; self.save_to_path(path, cx) } pub fn save_as(&mut self, path: PathBuf, cx: &mut Context) -> Task> { - self.resolve_pending_edit(cx); self.path = Some(path.clone()); self.save_to_path(path, cx) } fn save_to_path(&mut self, path: PathBuf, cx: &mut Context) -> Task> { - let saved_document = self.document.clone(); cx.spawn(async move |editor, cx| { let write_result = cx .background_executor() - .spawn({ - let saved_document = saved_document.clone(); - async move { save_flow_document(&path, &saved_document).map_err(std::io::Error::other) } - }) + .spawn(async move { flowstate_flow::save_flow_document(&path, &flowstate_flow::FlowDocument::new()).map_err(std::io::Error::other) }) .await; match write_result { Ok(()) => { let _ = editor.update(cx, |editor, cx| { - if editor.pending_edit.is_none() && editor.document == saved_document { - editor.dirty = false; - } + editor.dirty = false; cx.notify(); }); Ok(()) @@ -164,1354 +73,19 @@ impl FlowEditor { pub fn discard_recovery_file(&mut self) {} - pub fn json_snapshot(&mut self, cx: &mut Context) -> Option { - self.resolve_pending_edit(cx); - get_json(&self.document).ok() - } - - pub fn resolve_pending(&mut self, cx: &mut Context) { - self.resolve_pending_edit(cx); - } - - pub fn selected_flow_id(&self) -> Option<&str> { - self.selected_flow_id.as_deref() - } - - pub fn selected_flow_title(&self) -> String { - self - .selected_flow_id - .as_ref() - .and_then(|id| self.document.flow(id)) - .map(|flow| flow.content.clone()) - .unwrap_or_default() - } - - pub fn set_selected_flow_title(&mut self, value: String, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - self.on_title_change(&flow_id, value, cx); - } - - pub fn outline_items(&self) -> Vec { - self - .document - .flow_ids() - .iter() - .enumerate() - .map(|(index, id)| { - let flow = self.document.flow(id); - let label = flow - .map(|flow| { - if flow.content.is_empty() { - format!("Flow {}", index + 1) - } else { - flow.content.clone() - } - }) - .unwrap_or_else(|| "Missing flow".to_string()); - FlowOutlineItem { - id: id.clone(), - label, - index, - selected: self.selected_flow_id.as_deref() == Some(id.as_str()), - invert: flow.is_some_and(|flow| flow.invert), - } - }) - .collect() - } - - pub fn command_state(&self) -> FlowCommandState { - let selected_box = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)); - let can_format = selected_box - .as_ref() - .and_then(|id| self.document.box_node(id)) - .is_some_and(|box_node| !box_node.is_extension); - let can_fold = selected_box - .as_ref() - .and_then(|id| self.document.node(id)) - .is_some_and(|node| !node.children.is_empty()); - let selected_box_node = selected_box - .as_ref() - .and_then(|id| self.document.box_node(id)); - let owner = self - .selected_flow_id - .clone() - .unwrap_or_else(|| ROOT_ID.to_string()); - FlowCommandState { - has_flow: self.selected_flow_id.is_some(), - has_selected_box: selected_box.is_some(), - can_format, - can_fold, - selected_bold: selected_box_node.is_some_and(|box_node| box_node.bold), - selected_crossed: selected_box_node.is_some_and(|box_node| box_node.crossed), - selected_folded: selected_box - .as_ref() - .is_some_and(|id| self.folded.contains(id)), - can_undo: self.history.can_undo(&owner), - can_redo: self.history.can_redo(&owner), - } - } - - pub fn selected_style(&self) -> DebateStyleKey { - self.selected_style - } - - pub fn selected_style_label(&self) -> &'static str { - all_debate_style_templates() - .into_iter() - .find(|template| template.key == self.selected_style) - .map(|template| template.label) - .unwrap_or("Policy") - } - - #[hotpath::measure] - pub fn set_selected_style(&mut self, style: DebateStyleKey, cx: &mut Context) { - if self.selected_style != style { - self.selected_style = style; - self.switch_speakers = false; - cx.notify(); - } - } - - #[hotpath::measure] - pub fn ld_toc_circuit(&self) -> bool { - self.ld_toc_circuit - } - - #[hotpath::measure] - pub fn toggle_ld_toc_circuit(&mut self, cx: &mut Context) { - self.ld_toc_circuit = !self.ld_toc_circuit; - self.switch_speakers = false; - cx.notify(); - } - - #[hotpath::measure] - pub fn switch_speakers(&self) -> bool { - self.switch_speakers - } - - #[hotpath::measure] - pub fn toggle_switch_speakers(&mut self, cx: &mut Context) { - if self.has_switchable_templates() { - self.switch_speakers = !self.switch_speakers; - cx.notify(); - } - } - - #[hotpath::measure] - pub fn templates(&self) -> Vec { - let mut templates = debate_style_templates(self.selected_style, self.ld_toc_circuit); - if self.switch_speakers { - let mut flipped = Vec::with_capacity(templates.len()); - for pair in templates.chunks(2) { - if pair.len() == 2 { - flipped.push(pair[1].clone()); - flipped.push(pair[0].clone()); - } else if let Some(flow) = pair.first() { - flipped.push(flow.clone()); - } - } - templates = flipped; - } - templates - } - - #[hotpath::measure] - pub fn has_switchable_templates(&self) -> bool { - debate_style_templates(self.selected_style, self.ld_toc_circuit) - .iter() - .any(|template| template.columns_switch.is_some()) - } - - #[hotpath::measure] - pub fn select_flow(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - self.selected_flow_id = Some(flow_id.clone()); - let focus = self - .last_focus_ids - .get(&flow_id) - .cloned() - .unwrap_or(flow_id); - self.set_focus(Some(focus), window, cx); - } - - #[hotpath::measure] - fn set_focus(&mut self, focus_id: Option, window: &mut Window, cx: &mut Context) { - if self.focus_id != focus_id { - self.resolve_pending_edit(cx); - } - self.focus_id = focus_id.clone(); - if let Some(focus_id) = focus_id { - if let Some(flow_id) = self.document.parent_flow_id(&focus_id) { - self.last_focus_ids.insert(flow_id, focus_id.clone()); - } - self.focus_input(&focus_id, window, cx); - } - cx.notify(); - } - - #[hotpath::measure] - fn focus_input(&self, id: &str, window: &mut Window, cx: &mut Context) { - if let Some(input) = self.box_inputs.get(id) { - input.update(cx, |input, cx| input.focus(window, cx)); - } - } - - #[hotpath::measure] - fn perform_command(&mut self, command: CommandResult, before_focus: Option, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let after_focus = command.focus.clone().or(before_focus.clone()); - let inverse = self.document.apply_action_bundle(command.actions); - self - .history - .add(command.owner, inverse, before_focus, after_focus.clone()); - self.dirty = true; - if let Some(focus) = after_focus { - if self.document.node(&focus).is_some() { - self.set_focus(Some(focus), window, cx); - } else { - cx.notify(); - } - } else { - cx.notify(); - } - } - - #[hotpath::measure] - pub fn add_flow(&mut self, template: DebateStyleFlow, window: &mut Window, cx: &mut Context) { - let command = add_new_flow_actions(self.document.flow_ids().len(), &template, self.switch_speakers); - let new_flow_id = command.focus.clone(); - self.perform_command(command, self.focus_id.clone(), window, cx); - if let Some(flow_id) = new_flow_id { - self.selected_flow_id = Some(flow_id.clone()); - self.set_focus(Some(flow_id), window, cx); - } - } - - #[hotpath::measure] - pub fn delete_selected_flow(&mut self, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - let old_index = self - .document - .flow_ids() - .iter() - .position(|id| id == &flow_id) - .unwrap_or(0); - let Some(command) = delete_node_actions(&self.document, flow_id) else { - return; - }; - self.perform_command(command, self.focus_id.clone(), window, cx); - let next_flow = if self.document.flow_ids().is_empty() { - None - } else if old_index == 0 { - self.document.flow_ids().first().cloned() - } else { - self.document.flow_ids().get(old_index - 1).cloned() - }; - self.selected_flow_id = next_flow.clone(); - self.set_focus(next_flow, window, cx); - } - - #[hotpath::measure] - pub fn move_flow_to_index(&mut self, flow_id: NodeId, new_index: usize, window: &mut Window, cx: &mut Context) { - let Some(current_index) = self - .document - .flow_ids() - .iter() - .position(|id| id == &flow_id) - else { - return; - }; - let target_index = new_index.min(self.document.flow_ids().len().saturating_sub(1)); - if current_index == target_index { - self.select_flow(flow_id, window, cx); - return; - } - let Some(command) = move_node_actions(&self.document, flow_id.clone(), target_index) else { - return; - }; - self.perform_command(command, self.focus_id.clone(), window, cx); - self.selected_flow_id = Some(flow_id.clone()); - self.set_focus(Some(flow_id), window, cx); - } - - #[hotpath::measure] - fn add_empty_at_column(&mut self, level: usize, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - if let Some(command) = add_new_empty_actions(&self.document, flow_id, level) { - self.perform_command(command, self.focus_id.clone(), window, cx); - } - } - - #[hotpath::measure] - pub fn add_child_to_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self.focus_id.clone() else { - return; - }; - self.add_child(target_id, window, cx); - } - - #[hotpath::measure] - fn add_child(&mut self, target_id: NodeId, window: &mut Window, cx: &mut Context) { - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(flow_id) = self.document.parent_flow_id(&target_id) else { - return; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return; - }; - if node.level >= flow.columns.len() as i32 { - self.set_focus(Some(target_id), window, cx); - return; - } - let mut index = 0; - if let Some(first_child) = node.children.first() - && self - .document - .box_node(first_child) - .is_some_and(|box_node| box_node.is_extension) - { - index = 1; - } - if let Some(command) = add_new_box_actions(&self.document, target_id, index, None) { - self.perform_command(command, self.focus_id.clone(), window, cx); - } - } - - #[hotpath::measure] - pub fn add_sibling_to_focus(&mut self, direction: usize, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let index = self - .document - .child_index(&parent_id, &target_id) - .map(|index| index + direction) - .unwrap_or(direction); - if let Some(command) = add_new_box_actions(&self.document, parent_id, index, None) { - self.perform_command(command, Some(target_id), window, cx); - } - } - - pub fn insert_toolkit_text(&mut self, title: &str, text: &str, window: &mut Window, cx: &mut Context) { - let content = sanitize_input_value(text.trim().to_string()); - if content.is_empty() { - return; - } - self.resolve_pending_edit(cx); - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - let parent_id = self - .focus_id - .as_ref() - .filter(|id| self.document.check_box_id(id).is_some()) - .filter(|id| { - let Some(node) = self.document.node(id) else { - return false; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return false; - }; - node.level + 1 < flow.columns.len() as i32 - }) - .cloned() - .unwrap_or_else(|| flow_id.clone()); - let Some(parent) = self.document.node(&parent_id) else { - return; - }; - let box_id = new_box_id(); - let action = Action::Add { - parent: parent_id, - id: box_id.clone(), - index: parent.children.len(), - value: NodeValue::Box(BoxNode { - content, - flow_id: flow_id.clone(), - placeholder: (!title.trim().is_empty()).then(|| title.trim().to_string()), - empty: false, - crossed: false, - bold: false, - is_extension: false, - }), - }; - self.perform_command( - CommandResult { - actions: vec![action], - owner: flow_id, - focus: Some(box_id), - }, - self.focus_id.clone(), - window, - cx, - ); - } - - #[hotpath::measure] - pub fn extend_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if self - .document - .node(&target_id) - .and_then(|node| node.children.first()) - .and_then(|first| self.document.box_node(first)) - .is_some_and(|box_node| box_node.is_extension) - { - return; - } - if self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.is_extension) - { - return; - } - let Some(flow_id) = self.document.parent_flow_id(&target_id) else { - return; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return; - }; - let Some(node) = self.document.node(&target_id) else { - return; - }; - if node.level >= flow.columns.len() as i32 - 1 { - return; - } - if let Some(command) = add_new_extension_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); - } - } - - #[hotpath::measure] - pub fn delete_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id).cloned() else { - return; - }; - if parent.value == NodeValue::Root || (matches!(parent.value, NodeValue::Flow(_)) && parent.children.len() <= 1) { - self.set_focus(Some(target_id), window, cx); - return; - } - let target_index = parent - .children - .iter() - .position(|child| child == &target_id) - .unwrap_or(0); - let next_focus = if target_index > 0 { - parent.children.get(target_index - 1).cloned() - } else { - Some(parent_id.clone()).filter(|id| id != ROOT_ID) - }; - if let Some(command) = delete_node_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); - self.set_focus(next_focus, window, cx); - } - } - - #[hotpath::measure] - fn delete_empty_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if !self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.content.is_empty()) - { - return; - } - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id).cloned() else { - return; - }; - if parent.value == NodeValue::Root || (matches!(parent.value, NodeValue::Flow(_)) && parent.children.len() <= 1) { - return; - } - let target_index = parent - .children - .iter() - .position(|child| child == &target_id) - .unwrap_or(0); - let next_focus = if target_index > 0 { - parent.children.get(target_index - 1).cloned() - } else { - Some(parent_id.clone()).filter(|id| id != ROOT_ID) - }; - if let Some(command) = delete_node_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); - self.set_focus(next_focus, window, cx); - } - } - - #[hotpath::measure] - pub fn toggle_format_focus(&mut self, format: FormatKind, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.is_extension) - { - return; - } - if let Some(command) = toggle_box_format_actions(&self.document, target_id.clone(), format) { - self.perform_command(command, Some(target_id), window, cx); - } - } - - #[hotpath::measure] - pub fn toggle_fold_focus(&mut self, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if self - .document - .node(&target_id) - .is_none_or(|node| node.children.is_empty()) - { - return; - } - if !self.folded.insert(target_id.clone()) { - self.folded.remove(&target_id); - } - cx.notify(); - } - - #[hotpath::measure] - pub fn undo_selected(&mut self, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let Some(owner) = self.selected_flow_id.clone() else { - return; - }; - if let Some(focus) = self.history.undo(owner, &mut self.document) { - self.dirty = true; - self.set_focus(focus, window, cx); - } - } - - #[hotpath::measure] - pub fn redo_selected(&mut self, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let Some(owner) = self.selected_flow_id.clone() else { - return; - }; - if let Some(focus) = self.history.redo(owner, &mut self.document) { - self.dirty = true; - self.set_focus(focus, window, cx); - } - } - - #[hotpath::measure] - fn begin_or_update_edit(&mut self, id: NodeId, value: NodeValue, cx: &mut Context) { - if self.syncing_inputs.contains(&id) { - return; - } - let Some(old_node) = self.document.node(&id).cloned() else { - return; - }; - if old_node.value == value { - return; - } - let owner = match &value { - NodeValue::Flow(_) => id.clone(), - NodeValue::Box(_) => self - .document - .parent_flow_id(&id) - .unwrap_or_else(|| ROOT_ID.to_string()), - NodeValue::Root => ROOT_ID.to_string(), - }; - if self - .pending_edit - .as_ref() - .is_none_or(|pending| pending.id != id) - { - self.resolve_pending_edit(cx); - self.pending_edit = Some(PendingEdit { - id: id.clone(), - owner, - before_focus: self.focus_id.clone(), - after_focus: self.focus_id.clone(), - old_value: old_node.value.clone(), - }); - } else if let Some(pending) = &mut self.pending_edit { - pending.after_focus = self.focus_id.clone(); - } - if let Some(node) = self.document.node_mut(&id) { - node.value = value; - self.dirty = true; - cx.notify(); - } - } - - #[hotpath::measure] - fn resolve_pending_edit(&mut self, cx: &mut Context) { - let Some(pending) = self.pending_edit.take() else { - return; - }; - let Some(current) = self - .document - .node(&pending.id) - .map(|node| node.value.clone()) - else { - return; - }; - if current == pending.old_value { - return; - } - self.history.add( - pending.owner, - vec![Action::Update { - id: pending.id, - new_value: pending.old_value, - }], - pending.before_focus, - pending.after_focus, - ); - cx.notify(); - } - - #[hotpath::measure] - fn on_title_change(&mut self, flow_id: &str, value: String, cx: &mut Context) { - let Some(flow) = self.document.flow(flow_id).cloned() else { - return; - }; - let mut next = flow; - next.content = sanitize_input_value(value); - self.begin_or_update_edit(flow_id.to_string(), NodeValue::Flow(next), cx); - } - - #[hotpath::measure] - fn on_box_change(&mut self, box_id: &str, value: String, cx: &mut Context) { - let Some(box_node) = self.document.box_node(box_id).cloned() else { - return; - }; - let mut next = box_node; - next.content = sanitize_input_value(value); - self.begin_or_update_edit(box_id.to_string(), NodeValue::Box(next), cx); - } - - #[hotpath::measure] - fn focus_first_child(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) { - let focus = self - .document - .node(&flow_id) - .and_then(|flow| { - flow - .children - .iter() - .find(|child_id| { - self - .document - .box_node(child_id) - .is_some_and(|box_node| !box_node.empty) - }) - .cloned() - }) - .unwrap_or(flow_id); - self.set_focus(Some(focus), window, cx); - } - - #[hotpath::measure] - fn ensure_box_input(&mut self, box_id: &str, window: &mut Window, cx: &mut Context) -> Entity { - if let Some(input) = self.box_inputs.get(box_id) { - return input.clone(); - } - let box_node = self.document.box_node(box_id).cloned(); - let value = box_node - .as_ref() - .map(|box_node| box_node.content.clone()) - .unwrap_or_default(); - let placeholder = box_node - .and_then(|box_node| box_node.placeholder) - .unwrap_or_else(|| "type here".to_string()); - let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(placeholder) - .default_value(value) - }); - let id = box_id.to_string(); - let subscription = cx.subscribe_in(&input, window, move |editor, input, event: &InputEvent, window, cx| match event { - InputEvent::Change => { - let value = input.read(cx).value().to_string(); - editor.on_box_change(&id, value, cx); - }, - InputEvent::Focus => { - if editor.focus_id.as_deref() != Some(id.as_str()) { - editor.resolve_pending_edit(cx); - editor.focus_id = Some(id.clone()); - if let Some(flow_id) = editor.document.parent_flow_id(&id) { - editor.last_focus_ids.insert(flow_id, id.clone()); - } - cx.notify(); - } - }, - InputEvent::Blur => { - editor.resolve_pending_edit(cx); - }, - InputEvent::BackspaceEmpty | InputEvent::DeleteEmpty => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.delete_empty_focus(window, cx); - } - }, - InputEvent::PressEnter { secondary: false } => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.add_sibling_to_focus(1, window, cx); - } - }, - InputEvent::PressEnter { secondary: true } => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.add_child_to_focus(window, cx); - } - }, - }); - self.input_subscriptions.push(subscription); - self.box_inputs.insert(box_id.to_string(), input.clone()); - input - } - - #[hotpath::measure] - fn sync_input(&mut self, id: &str, input: &Entity, value: &str, window: &mut Window, cx: &mut Context) { - if input.read(cx).value().as_ref() == value { - return; - } - self.syncing_inputs.insert(id.to_string()); - input.update(cx, |input, cx| input.set_value(value.to_string(), window, cx)); - self.syncing_inputs.remove(id); - } - - #[hotpath::measure] - fn focused_box_input_is_focused(&self, window: &Window, cx: &App) -> bool { - self - .focus_id - .as_ref() - .and_then(|id| self.box_inputs.get(id)) - .is_some_and(|input| input.read(cx).focus_handle(cx).is_focused(window)) - } - - #[hotpath::measure] - fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; - let command_control = modifiers.platform || modifiers.control; - let mut handled = true; - - if matches!(key, "enter" | "backspace" | "delete") && self.focused_box_input_is_focused(window, cx) { - return; - } - - match (command_control, modifiers.shift, modifiers.alt, key) { - (true, true, _, "z") => self.redo_selected(window, cx), - (true, false, _, "z") => self.undo_selected(window, cx), - (true, false, _, "y") => self.redo_selected(window, cx), - (true, false, _, "n") => { - if let Some(template) = self.templates().first().cloned() { - self.add_flow(template, window, cx); - } - }, - (true, true, _, "n") => { - if let Some(template) = self.templates().get(1).cloned() { - self.add_flow(template, window, cx); - } - }, - (true, false, _, "b") => self.toggle_format_focus(FormatKind::Bold, window, cx), - (true, true, _, "x") => self.toggle_format_focus(FormatKind::Crossed, window, cx), - (true, false, _, "e") => self.extend_focus(window, cx), - (true, false, _, "backspace") | (true, false, _, "delete") => self.delete_focus(window, cx), - (false, false, false, "enter") => { - if let Some(flow_id) = self - .focus_id - .clone() - .filter(|id| self.document.flow(id).is_some()) - { - self.focus_first_child(flow_id, window, cx); - } else { - self.add_sibling_to_focus(1, window, cx); - } - }, - (false, true, _, "enter") => self.add_child_to_focus(window, cx), - (false, false, true, "enter") => self.add_sibling_to_focus(0, window, cx), - (false, false, _, "tab") => self.focus_sibling(1, window, cx), - (false, true, _, "tab") => self.focus_sibling_reverse(window, cx), - (false, false, _, "left") => self.focus_parent(window, cx), - (false, false, _, "right") => self.focus_first_child_of_focus(window, cx), - (false, false, _, "up") => self.focus_adjacent(-1, window, cx), - (false, false, _, "down") => self.focus_adjacent(1, window, cx), - (false, false, _, "backspace") => { - let can_delete_empty = self - .focus_id - .as_ref() - .and_then(|id| self.document.box_node(id)) - .is_some_and(|box_node| box_node.content.is_empty()); - if can_delete_empty { - self.delete_focus(window, cx); - } else { - handled = false; - } - }, - (false, false, _, "l") if modifiers.control => self.toggle_fold_focus(cx), - _ => handled = false, - } - - if handled { - window.prevent_default(); - cx.stop_propagation(); - } - } - - #[hotpath::measure] - fn focus_sibling(&mut self, direction: isize, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - let Some(node) = self.document.node(&focus_id) else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id) else { - return; - }; - let Some(index) = parent.children.iter().position(|id| id == &focus_id) else { - return; - }; - let next_index = if direction < 0 { - index.checked_sub(direction.unsigned_abs()) - } else { - Some(index + direction as usize).filter(|ix| *ix < parent.children.len()) - }; - if let Some(next_focus) = next_index.and_then(|ix| parent.children.get(ix)).cloned() { - self.set_focus(Some(next_focus), window, cx); - } else if parent_id != ROOT_ID { - self.set_focus(Some(parent_id), window, cx); - } - } - - #[hotpath::measure] - fn focus_sibling_reverse(&mut self, window: &mut Window, cx: &mut Context) { - self.focus_sibling(-1, window, cx); - } - - #[hotpath::measure] - fn focus_parent(&mut self, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(parent_id) = self - .document - .node(&focus_id) - .and_then(|node| node.parent.clone()) - .filter(|id| id != ROOT_ID) - else { - return; - }; - self.set_focus(Some(parent_id), window, cx); - } - - #[hotpath::measure] - fn focus_first_child_of_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(child_id) = self - .document - .node(&focus_id) - .and_then(|node| node.children.first()) - .cloned() - else { - return; - }; - self.set_focus(Some(child_id), window, cx); - } - - #[hotpath::measure] - fn focus_adjacent(&mut self, direction: isize, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - let mut ids = Vec::new(); - self.collect_visible_boxes(&flow_id, &mut ids); - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(index) = ids.iter().position(|id| id == &focus_id) else { - return; - }; - let next = if direction < 0 { - index - .checked_sub(direction.unsigned_abs()) - .and_then(|ix| ids.get(ix)) - .cloned() - } else { - ids.get(index + direction as usize).cloned() - }; - if let Some(next) = next { - self.set_focus(Some(next), window, cx); - } - } - - #[hotpath::measure] - fn collect_visible_boxes(&self, id: &str, ids: &mut Vec) { - let Some(node) = self.document.node(id) else { - return; - }; - if matches!(node.value, NodeValue::Box(_)) { - ids.push(id.to_string()); - } - if self.folded.contains(id) { - return; - } - for child in &node.children { - self.collect_visible_boxes(child, ids); - } - } - - #[hotpath::measure] - fn render_main(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(flow_id) = self.selected_flow_id.clone() else { - return self.render_empty_flow_state(cx).into_any_element(); - }; - if self.document.flow(&flow_id).is_none() { - return self.render_empty_flow_state(cx).into_any_element(); - } - v_flex() - .flex_1() - .size_full() - .overflow_hidden() - .bg(cx.theme().background) - .child(self.render_flow_board(flow_id, window, cx)) - .into_any_element() - } - - #[hotpath::measure] - fn render_empty_flow_state(&self, cx: &mut Context) -> impl IntoElement { - div() - .flex_1() - .size_full() - .flex() - .items_center() - .justify_center() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Choose a debate style and add a flow") - } - - #[hotpath::measure] - fn render_flow_board(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(flow) = self.document.flow(&flow_id).cloned() else { - return self.render_empty_flow_state(cx).into_any_element(); - }; - let width = px(COLUMN_WIDTH * flow.columns.len().max(1) as f32); - let children = self - .document - .node(&flow_id) - .map(|node| node.children.clone()) - .unwrap_or_default(); - v_flex() - .flex_1() - .overflow_scrollbar() - .p_4() - .child( - v_flex() - .w(width) - .min_w(width) - .gap_2() - .child( - h_flex() - .bg(cx.theme().background) - .children(flow.columns.iter().enumerate().map(|(ix, column)| { - let colors = flow_column_colors(column, cx); - h_flex() - .w(px(COLUMN_WIDTH)) - .h(px(34.0)) - .items_center() - .justify_center() - .gap_1() - .border_1() - .border_color(colors.border) - .bg(colors.background) - .text_color(colors.foreground) - .text_sm() - .font_weight(FontWeight::MEDIUM) - .child(div().truncate().child(column.clone())) - .child( - Button::new(("flow-add-empty-column", ix)) - .icon(IconName::Plus) - .xsmall() - .ghost() - .tooltip("Add argument in column") - .on_click(cx.listener(move |editor, _, window, cx| { - editor.add_empty_at_column(ix, window, cx); - })), - ) - .into_any_element() - })), - ) - .child( - v_flex().gap_2().children( - children - .into_iter() - .map(|child| self.render_box_tree(child, &flow.columns, window, cx)), - ), - ), - ) - .into_any_element() - } - - #[hotpath::measure] - fn render_box_tree(&mut self, box_id: NodeId, columns: &[String], window: &mut Window, cx: &mut Context) -> gpui::AnyElement { - let Some(node) = self.document.node(&box_id).cloned() else { - return div().into_any_element(); - }; - if self - .document - .box_node(&box_id) - .is_some_and(|box_node| box_node.empty) - { - return h_flex() - .items_start() - .child(div().w(px(COLUMN_WIDTH)).h(px(10.0)).flex_none()) - .child( - v_flex().gap_2().children( - node - .children - .into_iter() - .map(|child| self.render_box_tree(child, columns, window, cx)), - ), - ) - .into_any_element(); - } - let children = node.children.clone(); - h_flex() - .items_start() - .child(self.render_box_cell(box_id.clone(), node, columns, window, cx)) - .when(!self.folded.contains(&box_id), |this| { - this.child( - v_flex().gap_2().children( - children - .into_iter() - .map(|child| self.render_box_tree(child, columns, window, cx)), - ), - ) - }) - .when(self.folded.contains(&box_id), |this| { - this.child( - Button::new(("flow-unfold", stable_element_id(&box_id))) - .icon(IconName::PanelRightOpen) - .xsmall() - .ghost() - .tooltip("Unfold") - .on_click(cx.listener(move |editor, _, _, cx| { - editor.folded.remove(&box_id); - cx.notify(); - })), - ) - }) - .into_any_element() - } - - #[hotpath::measure] - fn render_box_cell( - &mut self, - box_id: NodeId, - node: Node, - columns: &[String], - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let Some(box_node) = self.document.box_node(&box_id).cloned() else { - return div().into_any_element(); - }; - let input = self.ensure_box_input(&box_id, window, cx); - self.sync_input(&box_id, &input, &box_node.content, window, cx); - if self.focus_id.as_deref() == Some(box_id.as_str()) && !input.focus_handle(cx).is_focused(window) { - input.update(cx, |input, cx| input.focus(window, cx)); - } - - let selected = self.focus_id.as_deref() == Some(box_id.as_str()); - let colors = columns - .get(flow_box_column_index(node.level)) - .map(|column| flow_column_colors(column, cx)) - .unwrap_or_else(|| affirmative_flow_colors(cx)); - let weak = cx.theme().muted_foreground; - let id_for_click = box_id.clone(); - v_flex() - .id(("flow-box-cell", stable_element_id(&box_id))) - .w(px(COLUMN_WIDTH)) - .min_h(px(44.0)) - .p_1() - .border_1() - .border_color(if selected { colors.selected_border } else { colors.border }) - .bg(if selected { colors.selected_background } else { colors.background }) - .text_color(if box_node.crossed { weak } else { colors.foreground }) - .when(box_node.bold, |this| this.font_weight(FontWeight::BOLD)) - .when(box_node.crossed, |this| this.line_through()) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(move |editor, _, window, cx| { - editor.set_focus(Some(id_for_click.clone()), window, cx); - })) - .child( - h_flex() - .w_full() - .items_start() - .gap_1() - .when(box_node.is_extension, |this| { - this.child( - Icon::new(IconName::ArrowRight) - .xsmall() - .text_color(colors.foreground), - ) - }) - .child( - div().flex_1().child( - Input::new(&input) - .appearance(false) - .bordered(false) - .focus_bordered(false) - .text_color(if box_node.crossed { weak } else { colors.foreground }) - .placeholder_color(colors.foreground.opacity(0.62)) - .w_full(), - ), - ), - ) - .into_any_element() - } + pub fn resolve_pending(&mut self, _cx: &mut Context) {} } impl EventEmitter<()> for FlowEditor {} -#[hotpath::measure_all] impl Focusable for FlowEditor { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Render for FlowEditor { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .overflow_hidden() - .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::on_key_down)) - .drag_over::(|style, _, _, cx| style.border_1().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(|editor, drag: &ToolkitTextDrag, window, cx| { - editor.insert_toolkit_text(&drag.title, &drag.text, window, cx); - })) - .child(self.render_main(window, cx)) - } -} - -#[hotpath::measure] -fn sanitize_input_value(value: String) -> String { - value.replace(['\r', '\n'], " ") -} - -#[derive(Clone, Copy, Debug)] -pub struct FlowSideColors { - pub background: Hsla, - pub border: Hsla, - pub foreground: Hsla, - pub selected_background: Hsla, - pub selected_border: Hsla, -} - -#[hotpath::measure] -pub fn affirmative_flow_colors(cx: &mut App) -> FlowSideColors { - FlowSideColors { - background: cx.theme().primary_hover, - border: cx.theme().primary, - foreground: cx.theme().primary_foreground, - selected_background: cx.theme().primary_active, - selected_border: cx.theme().primary_active, - } -} - -#[hotpath::measure] -pub fn flow_column_colors(column: &str, cx: &mut App) -> FlowSideColors { - let affirmative = affirmative_flow_colors(cx); - match debate_column_side(column) { - DebateColumnSide::Negative => affirmative.inverse(), - DebateColumnSide::Affirmative | DebateColumnSide::Unknown => affirmative, - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DebateColumnSide { - Affirmative, - Negative, - Unknown, -} - -#[hotpath::measure] -fn debate_column_side(column: &str) -> DebateColumnSide { - column - .split(|ch: char| !ch.is_ascii_alphanumeric()) - .find_map(debate_column_token_side) - .unwrap_or(DebateColumnSide::Unknown) -} - -#[hotpath::measure] -fn debate_column_token_side(token: &str) -> Option { - let token = token.trim().to_ascii_uppercase(); - if token.is_empty() { - return None; - } - - const AFFIRMATIVE_TOKENS: &[&str] = &["A1", "A2", "A3", "P1", "P2", "PC", "PR", "PM", "DPM", "MG", "GW", "PW"]; - const NEGATIVE_TOKENS: &[&str] = &["N1", "N2", "N3", "O1", "O2", "CC", "CR", "LO", "DLO", "MO", "CW", "OW", "OR"]; - - if token.contains("NEG") - || token.contains("CON") - || token.contains("OPP") - || token.contains("NC") - || token.contains("NR") - || token.contains("NFF") - || NEGATIVE_TOKENS.contains(&token.as_str()) - || token == "NS" - || token.ends_with('N') - { - return Some(DebateColumnSide::Negative); - } - - if token.contains("AFF") - || token.contains("PRO") - || token.contains("PROP") - || token.contains("AC") - || token.contains("AR") - || AFFIRMATIVE_TOKENS.contains(&token.as_str()) - || token == "AS" - || token.ends_with('A') - { - return Some(DebateColumnSide::Affirmative); - } - - None -} - -#[hotpath::measure_all] -impl FlowSideColors { - fn inverse(self) -> Self { - Self { - background: inverse_color(self.background), - border: inverse_color(self.border), - foreground: inverse_color(self.foreground), - selected_background: inverse_color(self.selected_background), - selected_border: inverse_color(self.selected_border), - } - } -} - -#[hotpath::measure] -fn inverse_color(color: Hsla) -> Hsla { - let rgb = color.to_rgb(); - Hsla::from(gpui::Rgba { - r: 1.0 - rgb.r, - g: 1.0 - rgb.g, - b: 1.0 - rgb.b, - a: rgb.a, - }) -} - -#[hotpath::measure] -fn flow_box_column_index(level: i32) -> usize { - level.saturating_sub(1).max(0) as usize -} - -#[hotpath::measure] -fn stable_element_id(value: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - hasher.finish() -} - -#[cfg(test)] -mod tests { - use super::{DebateColumnSide, debate_column_side, flow_box_column_index}; - - #[test] - #[hotpath::measure] - fn classifies_affirmative_speech_columns() { - for column in ["1AC", "2AC", "1AR", "2AR", "AC", "AFF", "Q/2A", "A3", "P1", "PC", "PM", "MG"] { - assert_eq!(debate_column_side(column), DebateColumnSide::Affirmative, "{column}"); - } - } - - #[test] - #[hotpath::measure] - fn classifies_negative_speech_columns() { - for column in ["1NC", "2NC/1NR", "1NR", "2NR", "NC", "NFF", "Q/1N", "N3", "O1", "CC", "LO", "MO"] { - assert_eq!(debate_column_side(column), DebateColumnSide::Negative, "{column}"); - } - } - - #[test] - #[hotpath::measure] - fn maps_flow_box_levels_to_column_indices() { - assert_eq!(flow_box_column_index(1), 0); - assert_eq!(flow_box_column_index(2), 1); - assert_eq!(flow_box_column_index(3), 2); + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full() } } diff --git a/crates/flowstate/src/flow/panel.rs b/crates/flowstate/src/flow/panel.rs index d086e776..f8197310 100644 --- a/crates/flowstate/src/flow/panel.rs +++ b/crates/flowstate/src/flow/panel.rs @@ -1,10 +1,9 @@ use std::path::PathBuf; use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, WeakEntity, Window, div, prelude::*}; -use gpui_component::ActiveTheme as _; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{Panel, PanelControl, PanelEvent, PanelInfo, PanelState}; -use gpui_component::{IconName, Sizable}; +use gpui_component::{ActiveTheme as _, IconName, Sizable}; use serde_json::json; use uuid::Uuid; @@ -22,7 +21,6 @@ pub struct FlowPanel { active: bool, } -#[hotpath::measure_all] impl FlowPanel { pub fn new_with_title( title: Option, @@ -33,9 +31,7 @@ impl FlowPanel { cx: &mut Context, ) -> Self { let ribbon = cx.new(|cx| FlowRibbon::new(editor.clone(), window, cx)); - let title = title - .map(Into::into) - .unwrap_or_else(|| title_for_path(path.as_ref())); + let title = title.map(Into::into).unwrap_or_else(|| title_for_path(path.as_ref())); Self { id: Uuid::new_v4(), title, @@ -83,7 +79,6 @@ impl FlowPanel { } } -#[hotpath::measure] fn title_for_path(path: Option<&PathBuf>) -> SharedString { path .and_then(|path| path.file_name()) @@ -94,30 +89,25 @@ fn title_for_path(path: Option<&PathBuf>) -> SharedString { impl EventEmitter for FlowPanel {} -#[hotpath::measure_all] impl Focusable for FlowPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Panel for FlowPanel { fn panel_name(&self) -> &'static str { "FlowPanel" } - #[hotpath::measure] fn tab_name(&self, cx: &App) -> Option { Some(self.display_title(cx)) } - #[hotpath::measure] fn title(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { self.display_title(cx).clone() } - #[hotpath::measure] fn title_suffix(&mut self, _: &mut Window, _cx: &mut Context) -> Option { let workspace = self.workspace.clone(); let panel_id = self.id; @@ -133,17 +123,14 @@ impl Panel for FlowPanel { ) } - #[hotpath::measure] fn closable(&self, _: &App) -> bool { false } - #[hotpath::measure] fn zoomable(&self, _: &App) -> Option { Some(PanelControl::Both) } - #[hotpath::measure] fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { self.active = active; if active { @@ -155,7 +142,6 @@ impl Panel for FlowPanel { } } - #[hotpath::measure] fn on_removed(&mut self, window: &mut Window, cx: &mut Context) { let panel_id = self.id; let _ = self.workspace.update(cx, |workspace, cx| { @@ -163,7 +149,6 @@ impl Panel for FlowPanel { }); } - #[hotpath::measure] fn dump(&self, _: &App) -> PanelState { PanelState { panel_name: self.panel_name().to_string(), @@ -176,18 +161,13 @@ impl Panel for FlowPanel { } } - #[hotpath::measure] fn inner_padding(&self, _: &App) -> bool { false } } -#[hotpath::measure_all] impl Render for FlowPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .bg(cx.theme().background) - .child(self.editor.clone()) + div().size_full().bg(cx.theme().background).child(self.editor.clone()) } } diff --git a/crates/flowstate/src/flow/ribbon.rs b/crates/flowstate/src/flow/ribbon.rs index ebc1296d..34e008da 100644 --- a/crates/flowstate/src/flow/ribbon.rs +++ b/crates/flowstate/src/flow/ribbon.rs @@ -1,468 +1,44 @@ -use flowstate_flow::{DebateStyleKey, FormatKind, all_debate_style_templates}; -use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Subscription, - Window, div, prelude::*, px, -}; -use gpui_component::button::{Button, ButtonVariants, Toggle}; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::select::{SearchableVec, Select, SelectEvent, SelectState}; -use gpui_component::{ActiveTheme as _, Disableable, Icon, IconName, PixelsExt, Selectable, Sizable, StyledExt, h_flex, v_flex}; +use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Window, div, prelude::*, px}; use crate::flow::FlowEditor; -type DebateStyleSelectDelegate = SearchableVec; - pub struct FlowRibbon { editor: Entity, focus_handle: FocusHandle, height: gpui::Pixels, - style_select: Entity>, - title_input: Entity, - syncing_title: bool, - _subscriptions: Vec, } -#[hotpath::measure_all] impl FlowRibbon { - pub fn new(editor: Entity, window: &mut Window, cx: &mut Context) -> Self { - let style_labels = all_debate_style_templates() - .iter() - .map(|template| SharedString::from(template.label)) - .collect::>(); - let style_select = cx.new(|cx| { - let mut select = SelectState::new(SearchableVec::new(style_labels), None, window, cx).searchable(true); - select.set_selected_value(&SharedString::from(editor.read(cx).selected_style_label()), window, cx); - select - }); - let title_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("flow name") - .default_value(editor.read(cx).selected_flow_title()) - }); - let style_editor = editor.clone(); - let style_subscription = cx.subscribe_in( - &style_select, - window, - move |_, _, event: &SelectEvent, _, cx| { - if let SelectEvent::Confirm(Some(label)) = event - && let Some(style) = style_key_for_label(label) - { - style_editor.update(cx, |editor, cx| editor.set_selected_style(style, cx)); - } - }, - ); - let title_editor = editor.clone(); - let title_subscription = cx.subscribe(&title_input, move |ribbon, input, event: &InputEvent, cx| match event { - InputEvent::Change => { - if ribbon.syncing_title { - return; - } - let value = input.read(cx).value().to_string(); - title_editor.update(cx, |editor, cx| editor.set_selected_flow_title(value, cx)); - }, - InputEvent::Blur => { - title_editor.update(cx, |editor, cx| editor.resolve_pending(cx)); - }, - InputEvent::Focus | InputEvent::PressEnter { .. } | InputEvent::BackspaceEmpty | InputEvent::DeleteEmpty => {}, - }); - + pub fn new(editor: Entity, _window: &mut Window, cx: &mut Context) -> Self { Self { editor, focus_handle: cx.focus_handle(), - height: default_flow_ribbon_height(), - style_select, - title_input, - syncing_title: false, - _subscriptions: vec![style_subscription, title_subscription], + height: px(112.0), } } + pub fn editor(&self) -> Entity { + self.editor.clone() + } + pub fn set_height(&mut self, height: gpui::Pixels, cx: &mut Context) { if self.height != height { self.height = height; cx.notify(); } } - - fn sync_controls(&mut self, window: &mut Window, cx: &mut Context) { - let style_label = SharedString::from(self.editor.read(cx).selected_style_label()); - if self - .style_select - .read(cx) - .selected_value() - .is_none_or(|selected| selected != &style_label) - { - self - .style_select - .update(cx, |select, cx| select.set_selected_value(&style_label, window, cx)); - } - - let title = self.editor.read(cx).selected_flow_title(); - if self.title_input.read(cx).value().as_ref() != title { - self.syncing_title = true; - self - .title_input - .update(cx, |input, cx| input.set_value(title, window, cx)); - self.syncing_title = false; - } - } - - fn render_setup_group(&mut self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let editor = self.editor.clone(); - let selected_style = editor.read(cx).selected_style(); - let ld_toc = editor.read(cx).ld_toc_circuit(); - let has_switch = editor.read(cx).has_switchable_templates(); - let switch_speakers = editor.read(cx).switch_speakers(); - let templates = editor.read(cx).templates(); - - let mut controls = Vec::::new(); - controls.push( - div() - .w(px(164.0)) - .child( - Select::new(&self.style_select) - .placeholder("Debate style") - .search_placeholder("Search styles") - .w_full(), - ) - .into_any_element(), - ); - if selected_style == DebateStyleKey::LincolnDouglas { - controls.push( - Toggle::new("flow-ribbon-ld-toc") - .label("TOC") - .xsmall() - .checked(ld_toc) - .on_click({ - let editor = editor.clone(); - move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_ld_toc_circuit(cx)); - } - }) - .into_any_element(), - ); - } - if has_switch { - controls.push( - Toggle::new("flow-ribbon-switch-speakers") - .label("Switch") - .xsmall() - .checked(switch_speakers) - .on_click({ - let editor = editor.clone(); - move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_switch_speakers(cx)); - } - }) - .into_any_element(), - ); - } - controls.extend(templates.into_iter().enumerate().map(|(ix, template)| { - let label = template.name.clone(); - flow_chip(("flow-ribbon-add-template", ix), metrics, cx) - .icon(IconName::Plus) - .label(label) - .tooltip("Add flow") - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_flow(template.clone(), window, cx)); - } - }) - .into_any_element() - })); - flow_ribbon_group("Setup", controls, metrics, cx) - } - - fn render_title_group(&mut self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - flow_ribbon_group( - "Flow", - vec![ - div() - .w(px(220.0)) - .child( - Input::new(&self.title_input) - .appearance(false) - .bordered(true) - .focus_bordered(true) - .w_full(), - ) - .into_any_element(), - flow_chip("flow-ribbon-delete-flow", metrics, cx) - .icon(IconName::Delete) - .danger() - .tooltip("Delete flow") - .disabled(!self.editor.read(cx).command_state().has_flow) - .on_click({ - let editor = self.editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.delete_selected_flow(window, cx)); - } - }) - .into_any_element(), - ], - metrics, - cx, - ) - } - - fn render_edit_group(&self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let state = self.editor.read(cx).command_state(); - let editor = self.editor.clone(); - flow_ribbon_group( - "Edit", - vec![ - flow_chip("flow-ribbon-undo", metrics, cx) - .icon(IconName::Undo) - .tooltip("Undo") - .disabled(!state.can_undo) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.undo_selected(window, cx)); - } - }), - flow_chip("flow-ribbon-redo", metrics, cx) - .icon(IconName::Redo) - .tooltip("Redo") - .disabled(!state.can_redo) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.redo_selected(window, cx)); - } - }), - flow_chip("flow-ribbon-add-child", metrics, cx) - .icon(IconName::ArrowRight) - .tooltip("Add response") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_child_to_focus(window, cx)); - } - }), - flow_chip("flow-ribbon-add-above", metrics, cx) - .icon(IconName::ArrowUp) - .tooltip("Add above") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_sibling_to_focus(0, window, cx)); - } - }), - flow_chip("flow-ribbon-add-below", metrics, cx) - .icon(IconName::ArrowDown) - .tooltip("Add below") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_sibling_to_focus(1, window, cx)); - } - }), - flow_chip("flow-ribbon-extend", metrics, cx) - .icon(IconName::ArrowRight) - .tooltip("Extend") - .disabled(!state.can_format) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.extend_focus(window, cx)); - } - }), - flow_chip("flow-ribbon-delete-box", metrics, cx) - .icon(IconName::Delete) - .tooltip("Delete selected") - .disabled(!state.has_selected_box) - .on_click(move |_, window, cx| { - editor.update(cx, |editor, cx| editor.delete_focus(window, cx)); - }), - ] - .into_iter() - .map(IntoElement::into_any_element) - .collect(), - metrics, - cx, - ) - } - - fn render_format_group(&self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let state = self.editor.read(cx).command_state(); - let editor = self.editor.clone(); - flow_ribbon_group( - "Format", - vec![ - flow_chip("flow-ribbon-bold", metrics, cx) - .child(Icon::default().path("icons/bold.svg").xsmall()) - .tooltip("Bold") - .disabled(!state.can_format) - .selected(state.selected_bold) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.toggle_format_focus(FormatKind::Bold, window, cx)); - } - }), - flow_chip("flow-ribbon-cross", metrics, cx) - .child(Icon::default().path("icons/strikethrough.svg").xsmall()) - .tooltip("Cross out") - .disabled(!state.can_format) - .selected(state.selected_crossed) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.toggle_format_focus(FormatKind::Crossed, window, cx)); - } - }), - flow_chip("flow-ribbon-fold", metrics, cx) - .icon(IconName::PanelRightClose) - .tooltip("Fold") - .disabled(!state.can_fold) - .selected(state.selected_folded) - .on_click(move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_fold_focus(cx)); - }), - ] - .into_iter() - .map(IntoElement::into_any_element) - .collect(), - metrics, - cx, - ) - } } impl EventEmitter<()> for FlowRibbon {} -#[hotpath::measure_all] impl Focusable for FlowRibbon { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Render for FlowRibbon { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_controls(window, cx); - let metrics = FlowRibbonMetrics::from_height(self.height); - div() - .w_full() - .h(metrics.height) - .min_h(min_flow_ribbon_height()) - .px(metrics.outer_padding_x) - .child( - h_flex() - .w_full() - .h_full() - .items_start() - .gap_2() - .bg(cx.theme().background) - .px(metrics.inner_padding_x) - .pt(metrics.group_padding_top) - .children([ - self.render_setup_group(metrics, cx).into_any_element(), - self.render_title_group(metrics, cx).into_any_element(), - self.render_edit_group(metrics, cx).into_any_element(), - self.render_format_group(metrics, cx).into_any_element(), - ]), - ) + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().w_full().h(self.height) } } - -#[derive(Clone, Copy)] -struct FlowRibbonMetrics { - height: gpui::Pixels, - chip_height: gpui::Pixels, - chip_padding_x: gpui::Pixels, - chip_gap: gpui::Pixels, - group_gap: gpui::Pixels, - group_padding_top: gpui::Pixels, - outer_padding_x: gpui::Pixels, - inner_padding_x: gpui::Pixels, -} - -#[hotpath::measure_all] -impl FlowRibbonMetrics { - fn from_height(height: gpui::Pixels) -> Self { - let height = px( - height - .as_f32() - .clamp(min_flow_ribbon_height().as_f32(), max_flow_ribbon_height().as_f32()), - ); - let scale = ((height.as_f32() - min_flow_ribbon_height().as_f32()) - / (max_flow_ribbon_height().as_f32() - min_flow_ribbon_height().as_f32())) - .clamp(0.0, 1.0); - Self { - height, - chip_height: px(20.0 + 10.0 * scale), - chip_padding_x: px(3.0 + 7.0 * scale), - chip_gap: px(2.0 + 4.0 * scale), - group_gap: px(4.0 + 7.0 * scale), - group_padding_top: px(3.0 + 3.0 * scale), - outer_padding_x: px(8.0), - inner_padding_x: px(8.0), - } - } -} - -#[hotpath::measure] -fn flow_ribbon_group(label: &'static str, controls: Vec, metrics: FlowRibbonMetrics, cx: &mut App) -> impl IntoElement { - v_flex() - .flex_none() - .gap_0p5() - .pr(metrics.group_gap) - .border_r_1() - .border_color(cx.theme().border.opacity(0.72)) - .child( - div() - .text_size(px(10.0)) - .font_medium() - .text_color(cx.theme().muted_foreground) - .child(label), - ) - .child( - h_flex() - .items_center() - .content_start() - .gap(metrics.chip_gap) - .flex_wrap() - .children(controls), - ) -} - -#[hotpath::measure] -fn flow_chip(id: impl Into, metrics: FlowRibbonMetrics, _cx: &mut App) -> Button { - Button::new(id) - .xsmall() - .compact() - .outline() - .h(metrics.chip_height) - .px(metrics.chip_padding_x) - .rounded(px(6.0)) -} - -#[hotpath::measure] -fn style_key_for_label(label: &SharedString) -> Option { - all_debate_style_templates() - .into_iter() - .find(|template| template.label == label.as_ref()) - .map(|template| template.key) -} - -#[hotpath::measure] -fn default_flow_ribbon_height() -> gpui::Pixels { - px(112.0) -} - -#[hotpath::measure] -fn min_flow_ribbon_height() -> gpui::Pixels { - px(56.0) -} - -#[hotpath::measure] -fn max_flow_ribbon_height() -> gpui::Pixels { - px(158.0) -} diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 0a235b57..59367753 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -492,8 +492,8 @@ impl Workspace { editor.update(cx, |editor, cx| editor.insert_toolkit_text_at_caret(paragraphs, cx)); return; } - if let Some(editor) = self.active_flow.clone() { - editor.update(cx, |editor, cx| editor.insert_toolkit_text(&hit.title, &hit.insert_text, window, cx)); + if self.active_flow.is_some() { + // Flow toolkit text insertion is not yet supported. } } @@ -690,7 +690,7 @@ impl Workspace { .justify_center() .child( Button::new(id) - .icon(Icon::new(IconName::FolderOpen).text_color(cx.theme().primary_foreground)) + .icon(Icon::new(IconName::FolderOpen).text_color(cx.theme().secondary_foreground)) .label(label) .small() .tooltip("Select debate tub folder") diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index fedf6c0f..b9f69b4e 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -136,143 +136,21 @@ impl Workspace { } fn render_flow_nav(&mut self, cx: &mut Context) -> AnyElement { - let workspace = cx.entity().downgrade(); - let items = self - .active_flow - .as_ref() - .map(|editor| editor.read(cx).outline_items()) - .unwrap_or_default(); - let len = items.len(); - v_flex() .size_full() .h_full() - .gap_1() .p_2() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( div() .w_full() + .h_full() .flex() - .flex_row() .items_center() - .justify_between() - .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().sidebar_primary) - .child("Flows"), - ) - .child( - Button::new("collapse-flow-outline-panel") - .icon(Icon::new(IconName::PanelLeftClose).text_color(cx.theme().sidebar_foreground)) - .xsmall() - .ghost() - .tooltip("Collapse outline") - .on_click(cx.listener(|workspace, _, _, cx| { - workspace.toggle_outline(cx); - })), - ), - ) - .child( - v_flex() - .flex_1() - .w_full() - .gap_1() - .overflow_y_scrollbar() - .children(items.into_iter().map(|item| { - let flow_id = item.id.clone(); - let label = item.label.clone(); - let selected = item.selected; - let source_index = item.index; - let target_index = item.index; - let colors = crate::flow::editor::affirmative_flow_colors(cx); - let workspace_for_click = workspace.clone(); - div() - .id(("flow-outline-drop-row", source_index)) - .w_full() - .on_drag( - FlowOutlineDrag { - flow_id: flow_id.clone(), - label: label.clone(), - source_index, - }, - |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }, - ) - .drag_over::(|this, _, _, cx| this.border_t_2().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(move |workspace, drag: &FlowOutlineDrag, window, cx| { - let new_index = flow_drop_index(drag.source_index, target_index); - if let Some(editor) = workspace.active_flow.clone() { - editor.update(cx, |editor, cx| editor.move_flow_to_index(drag.flow_id.clone(), new_index, window, cx)); - } - cx.notify(); - })) - .child( - ListItem::new(("flow-outline-item", source_index)) - .selected(selected) - .rounded(cx.theme().radius) - .on_click(move |_, window, cx| { - let _ = workspace_for_click.update(cx, |workspace, cx| { - if let Some(editor) = workspace.active_flow.clone() { - editor.update(cx, |editor, cx| editor.select_flow(flow_id.clone(), window, cx)); - } - }); - }) - .child( - h_flex() - .w_full() - .min_w_0() - .items_center() - .gap_2() - .child( - div() - .w(px(3.0)) - .h(px(20.0)) - .rounded(px(2.0)) - .bg(colors.border), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .truncate() - .text_color(if selected { - cx.theme().sidebar_foreground - } else { - cx.theme().muted_foreground - }) - .child(label), - ) - .child( - Icon::new(IconName::Menu) - .xsmall() - .text_color(cx.theme().muted_foreground), - ), - ), - ) - .into_any_element() - })) - .child( - div() - .id("flow-outline-drop-end") - .h(px(22.0)) - .rounded(cx.theme().radius) - .drag_over::(|this, _, _, cx| this.border_1().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(move |workspace, drag: &FlowOutlineDrag, window, cx| { - if len > 0 - && let Some(editor) = workspace.active_flow.clone() - { - editor.update(cx, |editor, cx| editor.move_flow_to_index(drag.flow_id.clone(), usize::MAX, window, cx)); - cx.notify(); - } - })), - ), + .justify_center() + .text_sm() + .child("Flow"), ) .into_any_element() } @@ -503,33 +381,7 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap ) } -#[derive(Clone)] -struct FlowOutlineDrag { - flow_id: String, - label: String, - source_index: usize, -} -#[hotpath::measure_all] -impl Render for FlowOutlineDrag { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .id("flow-outline-drag") - .w(px(180.0)) - .items_center() - .gap_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().drag_border) - .bg(cx.theme().popover.opacity(0.92)) - .px_2() - .py_1() - .text_xs() - .text_color(cx.theme().popover_foreground) - .child(Icon::new(IconName::Menu).xsmall()) - .child(div().flex_1().truncate().child(self.label.clone())) - } -} #[hotpath::measure] fn outline_hierarchy_color(depth: usize, cx: &App) -> Hsla { @@ -543,11 +395,4 @@ fn outline_hierarchy_color(depth: usize, cx: &App) -> Hsla { } } -#[hotpath::measure] -fn flow_drop_index(source_index: usize, target_index: usize) -> usize { - if source_index < target_index { - target_index.saturating_sub(1) - } else { - target_index - } -} + From 806ac5687c4e6ef189c517de34a7a6b36a4b3473 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Fri, 12 Jun 2026 22:39:26 -0500 Subject: [PATCH 02/24] Verbatim keymaps --- crates/flowstate/src/commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/flowstate/src/commands.rs b/crates/flowstate/src/commands.rs index a629516d..a93ed91b 100644 --- a/crates/flowstate/src/commands.rs +++ b/crates/flowstate/src/commands.rs @@ -206,8 +206,8 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::NextTab, "Next Tab", APP, &["ctrl-tab"]), CommandSpec::new(CommandId::PreviousTab, "Previous Tab", APP, &["ctrl-shift-tab"]), CommandSpec::new(CommandId::TogglePinTab, "Toggle Pin Tab", APP, &["ctrl-shift-p"]), - CommandSpec::new(CommandId::SendToSpeechDocument, "Send to Speech Document", APP, &["ctrl-`"]), - CommandSpec::new(CommandId::SendToSpeechDocumentEnd, "Send to Speech Document End", APP, &["ctrl-~"]), + CommandSpec::new(CommandId::SendToSpeechDocument, "Send to Speech Document", APP, &["`"]), + CommandSpec::new(CommandId::SendToSpeechDocumentEnd, "Send to Speech Document End", APP, &["alt-`"]), CommandSpec::new(CommandId::CondenseSelection, "Condense Selection", APP, &["f3"]), CommandSpec::new(CommandId::MarkCard, "Mark Card", APP, &["ctrl-m"]), CommandSpec::new(CommandId::CondensedSelection, "Shrink", APP, &[]), From 49679b6afec55fa886871a830917ee364dbe0ff7 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sat, 13 Jun 2026 05:07:22 -0500 Subject: [PATCH 03/24] UI fixed bigly --- crates/flowstate/assets/icons/replace-all.svg | 1 + crates/flowstate/assets/icons/replace.svg | 4 +- crates/flowstate/assets/icons/text-search.svg | 1 + crates/flowstate/src/app.rs | 8 + crates/flowstate/src/commands.rs | 10 +- crates/flowstate/src/commands/keymap.rs | 2 +- .../src/ribbon/editor_ribbon/core.rs | 6 +- .../src/ribbon/editor_ribbon/helpers.rs | 12 - .../src/ribbon/editor_ribbon/menus.rs | 55 +++- .../src/ribbon/editor_ribbon/modern_layout.rs | 18 +- .../flowstate/src/workspace/document_panel.rs | 33 ++- .../src/workspace/document_search_overlay.rs | 268 ++++++++++++++++-- .../flowstate/src/workspace/toolkit_panel.rs | 26 +- .../src/workspace/workspace/documents.rs | 5 - .../flowstate/src/workspace/workspace/mod.rs | 3 +- .../src/workspace/workspace/render_status.rs | 2 +- .../src/workspace/workspace/render_top_bar.rs | 1 - .../src/workspace/workspace/top_bar.rs | 14 +- .../workspace/workspace/workspace_state.rs | 26 -- vendor/gpui-component/src/icon.rs | 4 + .../gpui-component/src/menu/dropdown_menu.rs | 18 +- vendor/gpui-component/src/menu/mod.rs | 2 +- vendor/gpui-component/src/menu/popup_menu.rs | 137 ++++++++- 23 files changed, 520 insertions(+), 136 deletions(-) create mode 100644 crates/flowstate/assets/icons/replace-all.svg create mode 100644 crates/flowstate/assets/icons/text-search.svg diff --git a/crates/flowstate/assets/icons/replace-all.svg b/crates/flowstate/assets/icons/replace-all.svg new file mode 100644 index 00000000..8e8d8560 --- /dev/null +++ b/crates/flowstate/assets/icons/replace-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/flowstate/assets/icons/replace.svg b/crates/flowstate/assets/icons/replace.svg index ed209f37..68ddc9c3 100644 --- a/crates/flowstate/assets/icons/replace.svg +++ b/crates/flowstate/assets/icons/replace.svg @@ -1,3 +1 @@ - + \ No newline at end of file diff --git a/crates/flowstate/assets/icons/text-search.svg b/crates/flowstate/assets/icons/text-search.svg new file mode 100644 index 00000000..0f5cb948 --- /dev/null +++ b/crates/flowstate/assets/icons/text-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/flowstate/src/app.rs b/crates/flowstate/src/app.rs index 32b58681..6f298b17 100644 --- a/crates/flowstate/src/app.rs +++ b/crates/flowstate/src/app.rs @@ -361,6 +361,7 @@ impl AssetSource for AppAssets { "icons/letter-case.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/letter-case.svg")))), "icons/text-box-edit.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/text-box-edit.svg")))), "icons/replace.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/replace.svg")))), + "icons/replace-all.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/replace-all.svg")))), "icons/pin.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/pin.svg")))), "icons/paragraph-break-two.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/paragraph-break-two.svg")))), "icons/send-to-back.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/send-to-back.svg")))), @@ -370,6 +371,7 @@ impl AssetSource for AppAssets { "icons/send-horizontal.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/send-horizontal.svg")))), "icons/pdf.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/pdf.svg")))), "icons/docx.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/docx.svg")))), + "icons/text-search.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/text-search.svg")))), _ => gpui_component_assets::Assets.load(path), } } @@ -434,6 +436,9 @@ impl AssetSource for AppAssets { if "icons/replace.svg".starts_with(path) { assets.push("icons/replace.svg".into()); } + if "icons/replace-all.svg".starts_with(path) { + assets.push("icons/replace-all.svg".into()); + } if "icons/pin.svg".starts_with(path) { assets.push("icons/pin.svg".into()); } @@ -461,6 +466,9 @@ impl AssetSource for AppAssets { if "icons/docx.svg".starts_with(path) { assets.push("icons/docx.svg".into()); } + if "icons/text-search.svg".starts_with(path) { + assets.push("icons/text-search.svg".into()); + } Ok(assets) } } diff --git a/crates/flowstate/src/commands.rs b/crates/flowstate/src/commands.rs index a93ed91b..c0af4303 100644 --- a/crates/flowstate/src/commands.rs +++ b/crates/flowstate/src/commands.rs @@ -2,6 +2,7 @@ mod keymap; use gpui::actions; +pub(crate) use keymap::action_for_command; pub use keymap::{Keymap, KeymapEntry, register_default_keybindings, register_keymap}; actions!(flowstate_workspace, [FindInDocumentAction]); @@ -172,8 +173,8 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::Cut, "Cut", EDITOR, &["cmd-x", "ctrl-x"]), CommandSpec::new(CommandId::Paste, "Paste", EDITOR, &["cmd-v", "ctrl-v"]), CommandSpec::new(CommandId::Save, "Save", EDITOR, &["cmd-s", "ctrl-s"]), - CommandSpec::new(CommandId::Undo, "Undo", EDITOR, &["ctrl-z", "cmd-z"]), - CommandSpec::new(CommandId::Redo, "Redo", EDITOR, &["ctrl-y", "cmd-shift-z", "ctrl-shift-z"]), + CommandSpec::new(CommandId::Undo, "Undo", EDITOR, &["cmd-z", "ctrl-z"]), + CommandSpec::new(CommandId::Redo, "Redo", EDITOR, &["cmd-shift-z", "ctrl-shift-z", "ctrl-y"]), CommandSpec::new(CommandId::SetParagraphPocket, "Set Paragraph: Pocket", EDITOR, &["f4"]), CommandSpec::new(CommandId::SetParagraphHat, "Set Paragraph: Hat", EDITOR, &["f5"]), CommandSpec::new(CommandId::SetParagraphBlock, "Set Paragraph: Block", EDITOR, &["f6"]), @@ -282,3 +283,8 @@ pub fn label_for(id: CommandId) -> &'static str { .map(|spec| spec.label) .unwrap_or("Unknown Command") } + +#[hotpath::measure] +pub fn context_for(id: CommandId) -> Option<&'static str> { + command_spec(id).and_then(|spec| spec.context) +} diff --git a/crates/flowstate/src/commands/keymap.rs b/crates/flowstate/src/commands/keymap.rs index 4de74ee0..00a0d8cd 100644 --- a/crates/flowstate/src/commands/keymap.rs +++ b/crates/flowstate/src/commands/keymap.rs @@ -88,7 +88,7 @@ fn keybinding_for_entry(entry: &KeymapEntry) -> Option { } #[hotpath::measure] -fn action_for_command(command: CommandId) -> Option> { +pub(crate) fn action_for_command(command: CommandId) -> Option> { let action: Box = match command { CommandId::MoveLeft => Box::new(MoveLeft), CommandId::MoveRight => Box::new(MoveRight), diff --git a/crates/flowstate/src/ribbon/editor_ribbon/core.rs b/crates/flowstate/src/ribbon/editor_ribbon/core.rs index f2b14213..9b09f0d3 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/core.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/core.rs @@ -1,6 +1,6 @@ use gpui::{ - AnyElement, App, Context, Entity, Focusable, Hsla, IntoElement, Keystroke, ParentElement as _, Render, Styled as _, WeakEntity, Window, div, - prelude::*, px, relative, + Action, AnyElement, App, Context, Entity, Focusable, Hsla, IntoElement, Keystroke, ParentElement as _, Render, Styled as _, WeakEntity, Window, + div, prelude::*, px, relative, }; use gpui_component::Size; use gpui_component::button::DropdownButton; @@ -11,7 +11,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::{ActiveTheme as _, Disableable as _, Icon, IconName, PixelsExt as _, Selectable as _, Sizable as _}; use serde::{Deserialize, Serialize}; -use crate::commands::{CommandId, active_keys_for}; +use crate::commands::{CommandId, action_for_command, active_keys_for, context_for}; use crate::ribbon::style_catalog::{HIGHLIGHT_STYLE_SPECS, PARAGRAPH_STYLE_SPECS, SEMANTIC_STYLE_SPECS}; use crate::workspace::Workspace; use uuid::Uuid; diff --git a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs index 5c825d68..2c9b23cc 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs @@ -26,18 +26,6 @@ fn show_shortcut(options: ModernRibbonOptions) -> bool { } } -#[hotpath::measure] -fn command_tooltip(command: &RibbonCommand) -> String { - let label = match command.id { - RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => "Send to speech", - _ => command.label, - }; - match &command.shortcut { - Some(shortcut) => format!("{label} ({shortcut})"), - None => label.to_string(), - } -} - #[hotpath::measure] fn keycap(shortcut: String, cx: &mut Context) -> AnyElement { div() diff --git a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs index c3cc9d21..7983dcd4 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs @@ -120,6 +120,11 @@ fn modern_condense_menu( let label = RibbonLabel::for_command(command); let command_color = ribbon_command_color(command, cx); let shortcut = command.shortcut.clone(); + let condense_action = command + .command_id + .and_then(action_for_command) + .map(|action| (action, command.command_id.and_then(context_for))); + let has_condense_action = condense_action.is_some(); DropdownButton::new("modern-ribbon-condense-dropdown") .with_size(Size::Size(chip_height)) @@ -131,7 +136,10 @@ fn modern_condense_menu( .ghost() .h(chip_height) .px(metrics.chip_padding_x) - .tooltip(command_tooltip(command)) + .when_some(condense_action, |this, (action_box, ctx)| { + this.tooltip_with_action(command.label, &*action_box, ctx) + }) + .when(!has_condense_action, |this| this.tooltip(command.label)) .when_some(label.icon_path, |this, path| { this.child( Icon::default() @@ -517,12 +525,18 @@ fn modern_undo_button( cx: &mut Context, ) -> AnyElement { let enabled = editor.read(cx).can_undo(); - let tooltip = shortcut_for(CommandId::Undo) - .map(|s| format!("Undo ({s})")) - .unwrap_or_else(|| "Undo".to_string()); - modern_icon_chip(IconName::Undo, tooltip, command, editor, metrics, cx, enabled, |editor, cx| { - editor.undo(cx); - }) + let undo_action = action_for_command(CommandId::Undo).map(|a| (a, context_for(CommandId::Undo))); + modern_icon_chip( + IconName::Undo, + "Undo", + undo_action, + command, + editor, + metrics, + cx, + enabled, + |editor, cx| editor.undo(cx), + ) } #[hotpath::measure] @@ -533,17 +547,24 @@ fn modern_redo_button( cx: &mut Context, ) -> AnyElement { let enabled = editor.read(cx).can_redo(); - let tooltip = shortcut_for(CommandId::Redo) - .map(|s| format!("Redo ({s})")) - .unwrap_or_else(|| "Redo".to_string()); - modern_icon_chip(IconName::Redo, tooltip, command, editor, metrics, cx, enabled, |editor, cx| { - editor.redo(cx); - }) + let redo_action = action_for_command(CommandId::Redo).map(|a| (a, context_for(CommandId::Redo))); + modern_icon_chip( + IconName::Redo, + "Redo", + redo_action, + command, + editor, + metrics, + cx, + enabled, + |editor, cx| editor.redo(cx), + ) } fn modern_icon_chip( icon: IconName, - tooltip: String, + tooltip: &'static str, + action_with_ctx: Option<(Box, Option<&'static str>)>, command: &RibbonCommand, editor: Entity, metrics: RibbonLayoutMetrics, @@ -557,6 +578,7 @@ fn modern_icon_chip( } else { cx.theme().muted_foreground.opacity(0.5) }; + let has_tooltip_action = action_with_ctx.is_some(); Button::new(("modern-ribbon-command", ribbon_command_key(command.id))) .xsmall() .compact() @@ -565,7 +587,10 @@ fn modern_icon_chip( .w(metrics.chip_height) .px(metrics.chip_padding_x) .icon(Icon::new(icon).xsmall().text_color(icon_color)) - .tooltip(tooltip) + .when_some(action_with_ctx, |this, (action_box, ctx)| { + this.tooltip_with_action(tooltip, &*action_box, ctx) + }) + .when(!has_tooltip_action, |this| this.tooltip(tooltip)) .disabled(!enabled) .on_click(move |_, _, cx| { editor.update(cx, |editor, cx| action(editor, cx)); diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs index dfb09e67..7443cb77 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs @@ -193,12 +193,23 @@ fn modern_command_chip( cx: &mut Context, ) -> AnyElement { let command_id = command.id; - let tooltip = command_tooltip(command); let shortcut = command.shortcut.clone(); let label = RibbonLabel::for_command(command); let icon_path = label.prefers_icon().then_some(label.icon_path).flatten(); let command_color = ribbon_command_color(command, cx); + let tooltip_label = match command.id { + RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => { + "Send to speech" + } + _ => command.label, + }; + let tooltip_action = command + .command_id + .and_then(action_for_command) + .map(|action| (action, command.command_id.and_then(context_for))); + let has_tooltip_action = tooltip_action.is_some(); + Button::new(("modern-ribbon-command", ribbon_command_key(command_id))) .xsmall() .compact() @@ -210,7 +221,10 @@ fn modern_command_chip( .selected(command.selected) .disabled(command.disabled) .text_color(command_color) - .tooltip(tooltip) + .when_some(tooltip_action, |this, (action_box, ctx)| { + this.tooltip_with_action(tooltip_label, &*action_box, ctx) + }) + .when(!has_tooltip_action, |this| this.tooltip(tooltip_label)) .when(command.selected, |this| { this .border_color(command_color) diff --git a/crates/flowstate/src/workspace/document_panel.rs b/crates/flowstate/src/workspace/document_panel.rs index 8d41bddd..004a650b 100644 --- a/crates/flowstate/src/workspace/document_panel.rs +++ b/crates/flowstate/src/workspace/document_panel.rs @@ -58,6 +58,7 @@ impl DocumentPanel { }, DocumentSearchBarEvent::PreviousRequested => panel.select_previous_search_match(cx), DocumentSearchBarEvent::NextRequested => panel.select_next_search_match(cx), + DocumentSearchBarEvent::ApplyReplaceCurrentRequested => panel.apply_replace_current(cx), DocumentSearchBarEvent::ApplyReplaceRequested => panel.apply_replace(cx), DocumentSearchBarEvent::CloseRequested => panel.close_search_bar(cx), }); @@ -164,13 +165,17 @@ impl DocumentPanel { .find_text_with_options(&query, case_sensitive, whole_words); self.search_matches.retain(|range| { let search_bar = self.search_bar.read(cx); - self - .editor - .read(cx) - .document() - .paragraphs - .get(range.start.paragraph) - .is_some_and(|paragraph| search_bar.paragraph_style_enabled(paragraph.style)) + let paragraphs = &self.editor.read(cx).document().paragraphs; + let Some(paragraph) = paragraphs.get(range.start.paragraph) else { + return false; + }; + if !search_bar.paragraph_style_enabled(paragraph.style) { + return false; + } + if search_bar.has_active_run_filters() { + return search_bar.run_style_matches_for_range(paragraph, range); + } + true }); self.active_search_match = (!self.search_matches.is_empty()).then_some(0); } @@ -181,6 +186,20 @@ impl DocumentPanel { cx.notify(); } + fn apply_replace_current(&mut self, cx: &mut Context) { + self.refresh_search_matches(cx); + if self.search_matches.is_empty() || self.active_search_match.is_none() { + return; + } + let replacement = self.search_bar.read(cx).replacement(cx); + let replaced = self + .editor + .update(cx, |editor, cx| editor.replace_active_search_highlight(&replacement, cx)); + if replaced { + self.refresh_search_matches(cx); + } + } + fn apply_replace(&mut self, cx: &mut Context) { // Recompute immediately before replacing. Undo/redo can change the // document without going through the search bar, so cached match offsets diff --git a/crates/flowstate/src/workspace/document_search_overlay.rs b/crates/flowstate/src/workspace/document_search_overlay.rs index a0f5b184..d6c3afbe 100644 --- a/crates/flowstate/src/workspace/document_search_overlay.rs +++ b/crates/flowstate/src/workspace/document_search_overlay.rs @@ -3,15 +3,17 @@ use gpui::{ prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, + ActiveTheme as _, Icon, IconName, Side, Sizable as _, button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, input::{Input, InputEvent, InputState}, - menu::{DropdownMenu as _, PopupMenuItem}, + menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}, }; -use crate::rich_text_element::ParagraphStyle; +use std::ops::Range; + +use crate::rich_text_element::{DocumentOffset, HighlightStyle, Paragraph, ParagraphStyle, RunSemanticStyle}; #[derive(Clone, Copy)] struct SearchStyleFilter { @@ -50,6 +52,64 @@ const SEARCH_STYLE_FILTERS: &[SearchStyleFilter] = &[ }, ]; +#[derive(Clone, Copy)] +struct HighlightFilter { + label: &'static str, + style: HighlightStyle, +} + +const HIGHLIGHT_FILTERS: &[HighlightFilter] = &[ + HighlightFilter { + label: "Spoken", + style: flowstate_document::HIGHLIGHT_SPOKEN, + }, + HighlightFilter { + label: "Insert", + style: flowstate_document::HIGHLIGHT_INSERT, + }, + HighlightFilter { + label: "Alternative", + style: flowstate_document::HIGHLIGHT_ALTERNATIVE, + }, + HighlightFilter { + label: "Marked", + style: flowstate_document::HIGHLIGHT_MARKED, + }, +]; + +#[derive(Clone, Copy)] +struct SemanticStyleFilter { + label: &'static str, + style: RunSemanticStyle, +} + +const SEMANTIC_STYLE_FILTERS: &[SemanticStyleFilter] = &[ + SemanticStyleFilter { + label: "Plain", + style: RunSemanticStyle::Plain, + }, + SemanticStyleFilter { + label: "Cite", + style: flowstate_document::SEMANTIC_CITE, + }, + SemanticStyleFilter { + label: "Emphasis", + style: flowstate_document::SEMANTIC_EMPHASIS, + }, + SemanticStyleFilter { + label: "Underline", + style: flowstate_document::SEMANTIC_UNDERLINE, + }, + SemanticStyleFilter { + label: "Condensed", + style: flowstate_document::SEMANTIC_CONDENSED, + }, + SemanticStyleFilter { + label: "Ultracondensed", + style: flowstate_document::SEMANTIC_ULTRACONDENSED, + }, +]; + #[derive(Clone, Copy, Debug)] pub enum DocumentSearchBarEvent { QueryChanged, @@ -58,6 +118,7 @@ pub enum DocumentSearchBarEvent { StyleFilterChanged, PreviousRequested, NextRequested, + ApplyReplaceCurrentRequested, ApplyReplaceRequested, CloseRequested, } @@ -70,6 +131,10 @@ pub struct DocumentSearchBar { case_sensitive: bool, whole_words: bool, enabled_styles: [bool; SEARCH_STYLE_FILTERS.len()], + highlight_types: [bool; HIGHLIGHT_FILTERS.len()], + underline: bool, + strikethrough: bool, + semantic_styles: [bool; SEMANTIC_STYLE_FILTERS.len()], _input_subscription: Subscription, } @@ -94,6 +159,10 @@ impl DocumentSearchBar { case_sensitive: false, whole_words: false, enabled_styles: [true; SEARCH_STYLE_FILTERS.len()], + highlight_types: [true; HIGHLIGHT_FILTERS.len()], + underline: true, + strikethrough: true, + semantic_styles: [true; SEMANTIC_STYLE_FILTERS.len()], _input_subscription, } } @@ -138,6 +207,87 @@ impl DocumentSearchBar { .is_none_or(|ix| self.enabled_styles[ix]) } + pub fn has_active_run_filters(&self) -> bool { + self.highlight_types.iter().any(|&b| b) + || self.underline + || self.strikethrough + || self.semantic_styles.iter().any(|&b| b) + } + + pub fn run_style_matches_for_range(&self, paragraph: &Paragraph, range: &Range) -> bool { + let mut run_start = 0usize; + for run in ¶graph.runs { + let run_end = run_start + run.len; + if run_start < range.end.byte && run_end > range.start.byte { + if self.highlight_types.iter().any(|&b| b) + && let Some(highlight) = run.styles.highlight + && self.highlight_type_enabled(highlight) + { + return true; + } + if self.underline && run.styles.direct_underline { + return true; + } + if self.strikethrough && run.styles.strikethrough { + return true; + } + if self.semantic_styles.iter().any(|&b| b) + && self.semantic_style_enabled(run.styles.semantic) + { + return true; + } + } + run_start = run_end; + } + false + } + + fn semantic_style_enabled(&self, style: RunSemanticStyle) -> bool { + SEMANTIC_STYLE_FILTERS + .iter() + .position(|filter| filter.style == style) + .is_some_and(|ix| self.semantic_styles[ix]) + } + + fn highlight_type_enabled(&self, style: HighlightStyle) -> bool { + HIGHLIGHT_FILTERS + .iter() + .position(|filter| filter.style == style) + .is_some_and(|ix| self.highlight_types[ix]) + } + + fn toggle_highlight_type(&mut self, ix: usize, cx: &mut Context) { + self.highlight_types[ix] = !self.highlight_types[ix]; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_underline(&mut self, cx: &mut Context) { + self.underline = !self.underline; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_strikethrough(&mut self, cx: &mut Context) { + self.strikethrough = !self.strikethrough; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_semantic_style(&mut self, ix: usize, cx: &mut Context) { + self.semantic_styles[ix] = !self.semantic_styles[ix]; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + fn set_case_sensitive(&mut self, case_sensitive: bool, cx: &mut Context) { if self.case_sensitive == case_sensitive { return; @@ -252,7 +402,7 @@ impl Render for DocumentSearchBar { .gap_2() .items_center() .child( - Icon::new(IconName::Search) + Icon::new(IconName::TextSearch) .with_size(px(12.0)) .text_color(cx.theme().muted_foreground), ) @@ -376,9 +526,15 @@ impl Render for DocumentSearchBar { .tooltip("Paragraph styles to search") .dropdown_menu({ let search_bar = search_bar.clone(); - let enabled_styles = self.enabled_styles; - move |menu, _, _| { - SEARCH_STYLE_FILTERS + move |menu, window, cx| { + let sb = search_bar.upgrade().unwrap(); + let enabled_styles = sb.read(cx).enabled_styles; + let highlight_types = sb.read(cx).highlight_types; + let underline = sb.read(cx).underline; + let strikethrough = sb.read(cx).strikethrough; + let semantic_styles = sb.read(cx).semantic_styles; + + let mut menu = SEARCH_STYLE_FILTERS .iter() .enumerate() .fold(menu.min_w(px(140.0)), |menu, (ix, filter)| { @@ -386,11 +542,74 @@ impl Render for DocumentSearchBar { menu.item( PopupMenuItem::new(filter.label) .checked(enabled_styles[ix]) + .keep_open(true) .on_click(move |_, _, cx| { let _ = search_bar.update(cx, |bar, cx| bar.toggle_style_filter(filter.style, cx)); }), ) }) + .separator(); + for (ix, filter) in SEMANTIC_STYLE_FILTERS.iter().enumerate() { + let search_bar = search_bar.clone(); + menu = menu.item( + PopupMenuItem::new(filter.label) + .checked(semantic_styles[ix]) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_semantic_style(ix, cx)); + }), + ); + } + menu = menu.separator(); + + let any_highlight_checked = highlight_types.iter().any(|&b| b); + let highlight_submenu = PopupMenu::build(window, cx, |submenu, _window, _cx| { + let mut submenu = submenu.min_w(px(130.0)).check_side(Side::Right); + for (ix, filter) in HIGHLIGHT_FILTERS.iter().enumerate() { + let search_bar = search_bar.clone(); + submenu = submenu.item( + PopupMenuItem::new(filter.label) + .checked(highlight_types[ix]) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_highlight_type(ix, cx)); + }), + ); + } + submenu + }); + let parent = cx.entity().downgrade(); + highlight_submenu.update(cx, |sub, _| { + sub.parent_menu = Some(parent); + }); + + menu = menu + .item(PopupMenuItem::submenu("Highlight", highlight_submenu).checked(any_highlight_checked)) + .separator() + .item( + { + let search_bar = search_bar.clone(); + PopupMenuItem::new("Underline") + .checked(underline) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_underline(cx)); + }) + }, + ) + .item( + { + let search_bar = search_bar.clone(); + PopupMenuItem::new("Strikethrough") + .checked(strikethrough) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_strikethrough(cx)); + }) + }, + ); + + menu } }), ), @@ -404,16 +623,33 @@ impl Render for DocumentSearchBar { ), ) .child( - Button::new("apply-document-search-replace") - .icon( - Icon::default() - .path("icons/replace.svg") - .text_color(cx.theme().muted_foreground), + h_flex() + .gap_0() + .items_center() + .child( + Button::new("apply-document-search-replace-current") + .icon( + Icon::default() + .path("icons/replace.svg") + .text_color(cx.theme().muted_foreground), + ) + .xsmall() + .ghost() + .tooltip("Replace current match") + .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceCurrentRequested))), ) - .xsmall() - .ghost() - .tooltip("Replace all matches") - .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceRequested))), + .child( + Button::new("apply-document-search-replace-all") + .icon( + Icon::default() + .path("icons/replace-all.svg") + .text_color(cx.theme().muted_foreground), + ) + .xsmall() + .ghost() + .tooltip("Replace all matches") + .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceRequested))), + ), ) .child(div().flex_1()) .child( diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 59367753..3baae92b 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -27,7 +27,7 @@ use crate::{ }; use super::{ - APP_CHROME_BORDER_WIDTH, LeftNavMode, OutlineRowGuides, SIDE_PANEL_COLLAPSED_WIDTH, SidebarTreeAction, SidebarTreeRow, ToolkitSearchFilter, + APP_CHROME_BORDER_WIDTH, LeftNavMode, OutlineRowGuides, SidebarTreeAction, SidebarTreeRow, ToolkitSearchFilter, ToolkitTool, Workspace, outline_hierarchy_color, render_sidebar_tree_row, }; @@ -50,14 +50,12 @@ impl Workspace { /// search results are miniature scrollable windows that can be opened, /// inserted, or dragged into the editor. pub(super) fn render_content_area(&mut self, cx: &mut Context) -> impl IntoElement { - let toolkit_width = if self.toolkit_collapsed { - SIDE_PANEL_COLLAPSED_WIDTH - } else if self.active_toolkit_tool.is_some() { + let toolkit_width = if self.active_toolkit_tool.is_some() { px(380.0) } else { px(40.0) }; - let toolkit_range_end = if self.toolkit_collapsed || self.active_toolkit_tool.is_none() { + let toolkit_range_end = if self.active_toolkit_tool.is_none() { toolkit_width } else { px(620.0) @@ -82,13 +80,7 @@ impl Workspace { .size(toolkit_width) .size_range(toolkit_width..toolkit_range_end) .grow(false) - .child(if self.toolkit_collapsed { - self - .render_collapsed_side_panel("Show toolkit", IconName::PanelRightOpen, |workspace, cx| workspace.toggle_toolkit(cx), cx) - .into_any_element() - } else { - self.render_toolkit_rail_area(cx).into_any_element() - }), + .child(self.render_toolkit_rail_area(cx)), ) } @@ -143,16 +135,6 @@ impl Workspace { })), ) .child(div().flex_1()) - .child( - Button::new("collapse-toolkit-rail") - .icon(Icon::new(IconName::PanelRightClose).text_color(cx.theme().muted_foreground)) - .xsmall() - .ghost() - .tooltip("Hide toolkit") - .on_click(cx.listener(|workspace, _, _, cx| { - workspace.toggle_toolkit(cx); - })), - ) } pub(super) fn load_tub_root(&mut self, root: PathBuf, cx: &mut Context) { diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 7255ce76..3235215c 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -55,7 +55,6 @@ impl Workspace { active_flow: None, ribbon_collapsed: false, outline_collapsed: false, - toolkit_collapsed: false, active_toolkit_tool: None, recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), @@ -411,7 +410,6 @@ impl Workspace { active_index, ribbon_collapsed: self.ribbon_collapsed, outline_collapsed: self.outline_collapsed, - toolkit_collapsed: self.toolkit_collapsed, pinned_entry_indices, speech_entry_index, }); @@ -500,7 +498,6 @@ impl Workspace { } self.ribbon_collapsed = session.ribbon_collapsed; self.outline_collapsed = session.outline_collapsed; - self.toolkit_collapsed = session.toolkit_collapsed; self.pinned_document_ids = pinned_ids; self.speech_document_id = speech_id; @@ -1190,8 +1187,6 @@ struct TemporaryWorkspaceSession { #[serde(default)] outline_collapsed: bool, #[serde(default)] - toolkit_collapsed: bool, - #[serde(default)] pinned_entry_indices: Vec, #[serde(default)] speech_entry_index: Option, diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index 230b9d30..0c57df0b 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -40,7 +40,7 @@ use crate::app_settings::{ load_smart_word_selection, load_tub_root, save_autosave, save_document_theme, save_recent_documents, save_send_custom_directory, save_send_to_document_directory, save_smart_word_selection, save_theme_name, }; -use crate::commands::CommandId; +use crate::commands::{context_for, CommandId}; use crate::docx_conversion::convert_docx_to_document; use crate::flow::{FlowEditor, FlowPanel}; use crate::rich_text_element::{ @@ -70,7 +70,6 @@ pub struct Workspace { active_flow: Option>, ribbon_collapsed: bool, outline_collapsed: bool, - toolkit_collapsed: bool, active_toolkit_tool: Option, recent_documents: Vec, recent_document_previews: HashMap, diff --git a/crates/flowstate/src/workspace/workspace/render_status.rs b/crates/flowstate/src/workspace/workspace/render_status.rs index 7fede050..a7543e68 100644 --- a/crates/flowstate/src/workspace/workspace/render_status.rs +++ b/crates/flowstate/src/workspace/workspace/render_status.rs @@ -48,7 +48,7 @@ impl Workspace { ) .child( icon_button("tab-bar-save-file", AppIcon::SaveFile) - .tooltip("Save current file") + .tooltip_with_action("Save current file", &Save, context_for(CommandId::Save)) .on_click(cx.listener(|workspace, _, window, cx| { workspace.save_active(window, cx); })), diff --git a/crates/flowstate/src/workspace/workspace/render_top_bar.rs b/crates/flowstate/src/workspace/workspace/render_top_bar.rs index 0990071f..56350816 100644 --- a/crates/flowstate/src/workspace/workspace/render_top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/render_top_bar.rs @@ -20,7 +20,6 @@ impl Workspace { cx, !self.outline_collapsed, !self.ribbon_collapsed, - !self.toolkit_collapsed, )) .child(settings_top_bar_button(cx)), ) diff --git a/crates/flowstate/src/workspace/workspace/top_bar.rs b/crates/flowstate/src/workspace/workspace/top_bar.rs index d9397c6e..4e102b2a 100644 --- a/crates/flowstate/src/workspace/workspace/top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/top_bar.rs @@ -40,6 +40,8 @@ fn flowstate_top_bar_button(cx: &mut Context) -> impl IntoElement { menu.item( PopupMenuItem::new(label) .checked(selected) + .keep_open(true) + .radio(true) .on_hover({ let committed = committed.clone(); move |hovered, window, cx| { @@ -273,7 +275,7 @@ fn settings_top_bar_button(cx: &mut Context) -> impl IntoElement { } #[hotpath::measure] -fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_open: bool, toolkit_open: bool) -> impl IntoElement { +fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_open: bool) -> impl IntoElement { let workspace = cx.entity().downgrade(); div() .h_full() @@ -290,11 +292,11 @@ fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_o .dropdown_menu(move |menu, _, _| { let outline_workspace = workspace.clone(); let ribbon_workspace = workspace.clone(); - let toolkit_workspace = workspace.clone(); menu .item( PopupMenuItem::new("Outline") .checked(outline_open) + .keep_open(true) .on_click(move |_, _, cx| { let _ = outline_workspace.update(cx, |workspace, cx| workspace.toggle_outline(cx)); }), @@ -302,17 +304,11 @@ fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_o .item( PopupMenuItem::new("Ribbon") .checked(ribbon_open) + .keep_open(true) .on_click(move |_, _, cx| { let _ = ribbon_workspace.update(cx, |workspace, cx| workspace.toggle_ribbon(cx)); }), ) - .item( - PopupMenuItem::new("Toolkit") - .checked(toolkit_open) - .on_click(move |_, _, cx| { - let _ = toolkit_workspace.update(cx, |workspace, cx| workspace.toggle_toolkit(cx)); - }), - ) }), ) } diff --git a/crates/flowstate/src/workspace/workspace/workspace_state.rs b/crates/flowstate/src/workspace/workspace/workspace_state.rs index b54807c1..28373bcc 100644 --- a/crates/flowstate/src/workspace/workspace/workspace_state.rs +++ b/crates/flowstate/src/workspace/workspace/workspace_state.rs @@ -25,33 +25,7 @@ impl Workspace { cx.notify(); } - pub fn toggle_toolkit(&mut self, cx: &mut Context) { - let width = self - .content_resizable_state - .read(cx) - .sizes() - .get(1) - .copied() - .unwrap_or(px(300.0)); - let delta = if self.toolkit_collapsed { - SIDE_PANEL_COLLAPSED_WIDTH - width - } else { - width - SIDE_PANEL_COLLAPSED_WIDTH - }; - self.prepare_active_editor_for_width_delta(delta, cx); - self.toolkit_collapsed = !self.toolkit_collapsed; - if self.toolkit_collapsed { - self.active_toolkit_tool = None; - } - self.persist_temporary_workspace_session(cx); - cx.notify(); - } - fn toggle_toolkit_tool(&mut self, tool: ToolkitTool, cx: &mut Context) { - if self.toolkit_collapsed { - self.toolkit_collapsed = false; - } - let was_expanded = self.active_toolkit_tool.is_some(); self.active_toolkit_tool = if self.active_toolkit_tool == Some(tool) { None } else { Some(tool) }; diff --git a/vendor/gpui-component/src/icon.rs b/vendor/gpui-component/src/icon.rs index b6867a43..0d8f5921 100644 --- a/vendor/gpui-component/src/icon.rs +++ b/vendor/gpui-component/src/icon.rs @@ -89,6 +89,7 @@ pub enum IconName { Redo, Redo2, Replace, + ReplaceAll, ResizeCorner, Search, Settings, @@ -99,6 +100,7 @@ pub enum IconName { Star, StarOff, Sun, + TextSearch, ThumbsDown, ThumbsUp, TriangleAlert, @@ -187,6 +189,7 @@ impl IconNamed for IconName { Self::Redo => "icons/redo.svg", Self::Redo2 => "icons/redo-2.svg", Self::Replace => "icons/replace.svg", + Self::ReplaceAll => "icons/replace-all.svg", Self::ResizeCorner => "icons/resize-corner.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", @@ -197,6 +200,7 @@ impl IconNamed for IconName { Self::Star => "icons/star.svg", Self::StarOff => "icons/star-off.svg", Self::Sun => "icons/sun.svg", + Self::TextSearch => "icons/text-search.svg", Self::ThumbsDown => "icons/thumbs-down.svg", Self::ThumbsUp => "icons/thumbs-up.svg", Self::TriangleAlert => "icons/triangle-alert.svg", diff --git a/vendor/gpui-component/src/menu/dropdown_menu.rs b/vendor/gpui-component/src/menu/dropdown_menu.rs index 308f5cb9..b2fd5994 100644 --- a/vendor/gpui-component/src/menu/dropdown_menu.rs +++ b/vendor/gpui-component/src/menu/dropdown_menu.rs @@ -5,7 +5,12 @@ use gpui::{ RenderOnce, SharedString, StyleRefinement, Styled, Window, }; -use crate::{button::Button, menu::PopupMenu, popover::Popover, Selectable}; +use crate::{ + button::Button, + menu::{MenuChangedEvent, PopupMenu}, + popover::Popover, + Selectable, +}; /// A dropdown menu trait for buttons and other interactive elements pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { @@ -128,6 +133,17 @@ where }) .detach(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &MenuChangedEvent, _window, cx| { + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + menu.clone() } }; diff --git a/vendor/gpui-component/src/menu/mod.rs b/vendor/gpui-component/src/menu/mod.rs index 3152a15c..026af60e 100644 --- a/vendor/gpui-component/src/menu/mod.rs +++ b/vendor/gpui-component/src/menu/mod.rs @@ -9,7 +9,7 @@ mod popup_menu; pub use app_menu_bar::AppMenuBar; pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; pub use dropdown_menu::DropdownMenu; -pub use popup_menu::{PopupMenu, PopupMenuItem}; +pub use popup_menu::{MenuChangedEvent, PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { app_menu_bar::init(cx); diff --git a/vendor/gpui-component/src/menu/popup_menu.rs b/vendor/gpui-component/src/menu/popup_menu.rs index 3a4ea3e9..779727f1 100644 --- a/vendor/gpui-component/src/menu/popup_menu.rs +++ b/vendor/gpui-component/src/menu/popup_menu.rs @@ -11,6 +11,10 @@ use gpui::{ WeakEntity, Window, anchored, canvas, div, prelude::FluentBuilder, px, rems, }; use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Subscription}; + +#[derive(Clone, Copy)] +pub struct MenuChangedEvent; + use std::rc::Rc; const CONTEXT: &str = "PopupMenu"; @@ -38,6 +42,8 @@ pub enum PopupMenuItem { label: SharedString, disabled: bool, checked: bool, + keep_open: bool, + radio: bool, is_link: bool, action: Option>, // For link item @@ -49,6 +55,8 @@ pub enum PopupMenuItem { icon: Option, disabled: bool, checked: bool, + keep_open: bool, + radio: bool, action: Option>, render: Box AnyElement + 'static>, handler: Option>, @@ -61,6 +69,7 @@ pub enum PopupMenuItem { icon: Option, label: SharedString, disabled: bool, + checked: bool, menu: Entity, }, } @@ -75,6 +84,8 @@ impl PopupMenuItem { label: label.into(), disabled: false, checked: false, + keep_open: false, + radio: false, action: None, is_link: false, handler: None, @@ -93,6 +104,8 @@ impl PopupMenuItem { icon: None, disabled: false, checked: false, + keep_open: false, + radio: false, action: None, render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: None, @@ -107,6 +120,7 @@ impl PopupMenuItem { icon: None, label: label.into(), disabled: false, + checked: false, menu, } } @@ -188,6 +202,9 @@ impl PopupMenuItem { PopupMenuItem::ElementItem { checked: c, .. } => { *c = checked; } + PopupMenuItem::Submenu { checked: c, .. } => { + *c = checked; + } _ => {} } self @@ -212,6 +229,34 @@ impl PopupMenuItem { self } + /// Keep the menu open after clicking this item. + /// + /// Useful for checkbox items in filter menus where the user may want + /// to toggle multiple options without the menu closing after each one. + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn keep_open(mut self, keep: bool) -> Self { + match &mut self { + PopupMenuItem::Item { keep_open, .. } => *keep_open = keep, + PopupMenuItem::ElementItem { keep_open, .. } => *keep_open = keep, + _ => {} + } + self + } + + /// Make this item behave as a radio button — when clicked with + /// [`keep_open`](Self::keep_open), all other items in the same menu + /// are unchecked first. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn radio(mut self, is_radio: bool) -> Self { + match &mut self { + PopupMenuItem::Item { radio, .. } => *radio = is_radio, + PopupMenuItem::ElementItem { radio, .. } => *radio = is_radio, + _ => {} + } + self + } + /// Add a hover handler for the menu item. /// /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. @@ -244,6 +289,8 @@ impl PopupMenuItem { label: label.into(), disabled: false, checked: false, + keep_open: false, + radio: false, action: None, is_link: true, handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))), @@ -282,7 +329,9 @@ impl PopupMenuItem { PopupMenuItem::ElementItem { icon, checked, .. } => { icon.is_some() || (check_side.is_left() && *checked) } - PopupMenuItem::Submenu { icon, .. } => icon.is_some(), + PopupMenuItem::Submenu { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } _ => false, } } @@ -292,6 +341,7 @@ impl PopupMenuItem { match self { PopupMenuItem::Item { checked, .. } => *checked, PopupMenuItem::ElementItem { checked, .. } => *checked, + PopupMenuItem::Submenu { checked, .. } => *checked, _ => false, } } @@ -311,7 +361,7 @@ pub struct PopupMenu { check_side: Side, /// The parent menu of this menu, if this is a submenu - parent_menu: Option>, + pub parent_menu: Option>, scrollable: bool, scrollbar_show: Option, external_link_icon: bool, @@ -769,7 +819,11 @@ impl PopupMenu { let item = self.menu_items.get(index); match item { Some(PopupMenuItem::Item { - handler, action, .. + handler, + action, + keep_open, + radio, + .. }) => { if let Some(handler) = handler { handler(&ClickEvent::default(), window, cx); @@ -777,17 +831,63 @@ impl PopupMenu { self.dispatch_confirm_action(action, window, cx); } - self.dismiss(&Cancel, window, cx) + if *keep_open { + if *radio { + for item in self.menu_items.iter_mut() { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = false; + } + } + if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = true; + } + } + } else if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = !*checked; + } + } + cx.notify(); + self.keep_open_confirmed(window, cx); + } else { + self.dismiss(&Cancel, window, cx) + } } Some(PopupMenuItem::ElementItem { - handler, action, .. + handler, + action, + keep_open, + radio, + .. }) => { if let Some(handler) = handler { handler(&ClickEvent::default(), window, cx); } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action, window, cx); } - self.dismiss(&Cancel, window, cx) + if *keep_open { + if *radio { + for item in self.menu_items.iter_mut() { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = false; + } + } + if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = true; + } + } + } else if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = !*checked; + } + } + cx.notify(); + self.keep_open_confirmed(window, cx); + } else { + self.dismiss(&Cancel, window, cx) + } } _ => {} } @@ -977,6 +1077,26 @@ impl PopupMenu { }); } + pub(crate) fn keep_open_confirmed(&mut self, _: &mut Window, cx: &mut Context) { + let any_checked = self.menu_items.iter().any(|i| i.is_checked()); + if let Some(parent_entity) = self.parent_menu.as_ref().and_then(|p| p.upgrade()) { + let current = cx.entity().downgrade(); + parent_entity.update(cx, |parent, cx| { + if let Some(current_entity) = current.upgrade() { + for item in parent.menu_items.iter_mut() { + if let PopupMenuItem::Submenu { checked, menu, .. } = item { + if menu.entity_id() == current_entity.entity_id() { + *checked = any_checked; + } + } + } + } + cx.notify(); + }); + } + cx.notify(); + } + fn render_key_binding( &self, action: Option>, @@ -1204,6 +1324,7 @@ impl PopupMenu { label, menu, disabled, + .. } => this .selected(selected) .disabled(*disabled) @@ -1216,7 +1337,7 @@ impl PopupMenu { .gap_x_1() .children(Self::render_icon( has_left_icon, - false, + options.check_side.is_left() && item.is_checked(), icon.clone(), window, cx, @@ -1228,6 +1349,7 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) + .children(right_check_icon.map(|icon| icon.ml_3())) .child(IconName::ChevronRight), ), ) @@ -1256,6 +1378,7 @@ impl PopupMenu { impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} +impl EventEmitter for PopupMenu {} impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() From 5aad1df342b878434bfd074441d1801be297aacd Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sat, 13 Jun 2026 14:00:32 -0500 Subject: [PATCH 04/24] Logical flowing api --- Cargo.lock | 530 ++++++++- crates/flowstate-flow/Cargo.toml | 6 + crates/flowstate-flow/src/document.rs | 734 +++++++++++- crates/flowstate-flow/src/lib.rs | 10 +- crates/flowstate-flow/src/operations.rs | 420 +++++++ crates/flowstate-flow/src/persistence.rs | 77 +- .../assets/icons/remove-formatting.svg | 1 + crates/flowstate/src/app.rs | 4 + crates/flowstate/src/commands.rs | 8 +- crates/flowstate/src/commands/keymap.rs | 1 + crates/flowstate/src/flow/editor.rs | 1031 ++++++++++++++++- crates/flowstate/src/flow/mod.rs | 2 +- crates/flowstate/src/flow/ribbon.rs | 193 ++- .../src/ribbon/editor_ribbon/menus.rs | 5 + .../src/ribbon/editor_ribbon/modern_layout.rs | 2 +- .../src/ribbon/editor_ribbon/modern_types.rs | 2 +- crates/flowstate/src/rich_text_element/mod.rs | 5 +- .../flowstate/src/workspace/toolkit_panel.rs | 9 +- .../src/workspace/workspace/documents.rs | 16 +- .../src/workspace/workspace/keybindings.rs | 41 + .../src/workspace/workspace/render_body.rs | 10 +- .../src/workspace/workspace/render_outline.rs | 268 ++++- .../src/workspace/workspace/traits.rs | 2 +- .../src/rich_text/editor/action_handlers.rs | 3 + .../src/rich_text/editor/commands.rs | 13 + .../gpui-flowtext/src/rich_text/editor/mod.rs | 6 +- 26 files changed, 3312 insertions(+), 87 deletions(-) create mode 100644 crates/flowstate-flow/src/operations.rs create mode 100644 crates/flowstate/assets/icons/remove-formatting.svg diff --git a/Cargo.lock b/Cargo.lock index 247ab1eb..92155a1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + [[package]] name = "approx" version = "0.5.1" @@ -177,6 +183,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -210,6 +219,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -694,6 +709,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitpacking" version = "0.9.3" @@ -868,7 +892,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1768,14 +1792,38 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1791,13 +1839,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1830,6 +1889,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1843,6 +1913,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -2159,6 +2235,36 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "ensure-cov" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -2199,6 +2305,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2586,6 +2704,12 @@ name = "flowstate-flow" version = "0.1.0" dependencies = [ "anyhow", + "flowstate-document", + "loro", + "postcard", + "serde", + "uuid", + "zstd", ] [[package]] @@ -2902,6 +3026,21 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2912,6 +3051,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-btree" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" +dependencies = [ + "arref", + "heapless 0.9.3", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash 2.1.2", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -3491,6 +3644,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3652,13 +3814,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -4267,6 +4449,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "image" version = "0.25.10" @@ -4467,6 +4664,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -4660,6 +4866,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -4874,6 +5086,21 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -4915,6 +5142,144 @@ dependencies = [ "weezl", ] +[[package]] +name = "loro" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd1b63cd2f0cf66acbbb3191c41feaff03c94c3f6c6bb1e61d2ab3062c301c7" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash 2.1.2", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51042936156a1d537df8a38938ec8ed24b45de14530af933c1d02de39c9e056" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash 2.1.2", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f381e7fb5d4bf5605b4f4a9241f3adf61403ded978d757552004c8302cccbb" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + +[[package]] +name = "loro-internal" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252a84196402ac55a7dd064af87a747a430fc176ec744d5bb99f4ed92e639fa8" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.17", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand 0.8.6", + "rustc-hash 2.1.2", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950cc8bcc64dcff536e949fbc56ccc3d0759646fa441c7f76b3cd7c3eafa2096" +dependencies = [ + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex 0.11.6", + "once_cell", + "quick_cache", + "rustc-hash 2.1.2", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-rle" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca7180674d0273ddf37049a5efcde4547fd5330d24abb7519bb9d9eb6780d5b" +dependencies = [ + "once_cell", + "rand 0.8.6", + "serde", +] + [[package]] name = "lru" version = "0.16.4" @@ -4995,6 +5360,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + [[package]] name = "lz4_flex" version = "0.13.1" @@ -5089,6 +5463,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "measure_time" version = "0.9.0" @@ -5410,6 +5790,12 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -6033,6 +6419,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + [[package]] name = "phf" version = "0.11.3" @@ -6292,7 +6721,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "serde", ] @@ -6332,6 +6761,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -6539,6 +6978,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" +dependencies = [ + "ahash 0.8.12", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -6697,6 +7148,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -7542,6 +8002,31 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_columnar" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" +dependencies = [ + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_columnar_derive" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7784,6 +8269,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "sketches-ddsketch" version = "0.4.0" @@ -7843,6 +8338,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol" @@ -8347,7 +8845,7 @@ dependencies = [ "levenshtein_automata", "log", "lru", - "lz4_flex", + "lz4_flex 0.13.1", "measure_time", "memmap2", "once_cell", @@ -9044,6 +9542,12 @@ dependencies = [ "syntect", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typed-arena" version = "2.0.2" @@ -9360,6 +9864,12 @@ dependencies = [ "unicode-math-class", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -10882,6 +11392,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.2.1" diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index ddf759a6..e6e71cff 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -9,3 +9,9 @@ workspace = true [dependencies] anyhow = { workspace = true } +flowstate-document.workspace = true +loro = "1.13.1" +postcard = "1.1.3" +serde = { workspace = true, features = ["derive"] } +uuid = { workspace = true, features = ["serde", "v4"] } +zstd.workspace = true diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index 4db2d1cd..16f77728 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -1,8 +1,736 @@ -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FlowDocument; +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context as _, bail}; +use loro::{ExportMode, LoroDoc, LoroValue, UndoManager, VersionVector, ValueOrContainer}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub type FormatId = Uuid; +pub type SheetTypeId = Uuid; +pub type SheetId = Uuid; +pub type ColumnId = Uuid; +pub type CellId = Uuid; +pub type StrokeId = Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AnnotationOriginator(pub String); + +const PROJECTION_KEY: &str = "projection"; +const FORMAT_KEY: &str = "immutable-format"; +const ANNOTATIONS_MAP: &str = "flow-annotations"; +const SHEETS_MAP: &str = "flow-sheets"; +const CELLS_MAP: &str = "flow-cells"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArgumentSide { + One, + Two, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColumnDefinition { + pub id: ColumnId, + pub label: String, + pub side: ArgumentSide, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SheetTypeDefinition { + pub id: SheetTypeId, + pub name: String, + pub columns: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFormat { + pub id: FormatId, + pub name: String, + pub sheet_types: Vec, +} + +impl FlowFormat { + pub fn policy_debate() -> Self { + let affirmative = sheet_type( + "Affirmative", + &[ + ("1AC", ArgumentSide::One), + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + let negative = sheet_type( + "Negative", + &[ + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + Self { + id: Uuid::new_v4(), + name: "Policy Debate".into(), + sheet_types: vec![affirmative, negative], + } + } + + pub fn sheet_type(&self, id: SheetTypeId) -> Option<&SheetTypeDefinition> { + self.sheet_types.iter().find(|definition| definition.id == id) + } +} + +fn sheet_type(name: &str, columns: &[(&str, ArgumentSide)]) -> SheetTypeDefinition { + SheetTypeDefinition { + id: Uuid::new_v4(), + name: name.into(), + columns: columns + .iter() + .map(|(label, side)| ColumnDefinition { + id: Uuid::new_v4(), + label: (*label).into(), + side: *side, + }) + .collect(), + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cell { + pub id: CellId, + pub column_id: ColumnId, + pub parent_id: Option, + pub document_bytes: Vec, +} + +impl Cell { + pub fn plain(column_id: ColumnId) -> anyhow::Result { + let document = flowstate_document::document_from_input( + flowstate_document::flowstate_document_theme(), + vec![flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![flowstate_document::InputRun { + text: String::new(), + styles: flowstate_document::RunStyles::default(), + }], + }], + ); + Ok(Self { + id: Uuid::new_v4(), + column_id, + parent_id: None, + document_bytes: flowstate_document::db8_bytes(&document)?, + }) + } + + pub fn document(&self) -> std::io::Result { + flowstate_document::read_db8_bytes(&self.document_bytes) + } + + pub fn is_empty(&self) -> std::io::Result { + let document = self.document()?; + Ok(document.text.to_string().trim().is_empty() && document.blocks.len() == document.paragraphs.len()) + } + + pub fn summary_text(&self) -> std::io::Result { + let document = self.document()?; + if let Some((index, _)) = document + .paragraphs + .iter() + .enumerate() + .find(|(_, paragraph)| paragraph.style == flowstate_document::PARAGRAPH_ANALYTIC) + { + return Ok(paragraph_text(&document, index)); + } + if let Some((tag_index, _)) = document + .paragraphs + .iter() + .enumerate() + .find(|(_, paragraph)| paragraph.style == flowstate_document::PARAGRAPH_TAG) + { + let mut summary = paragraph_text(&document, tag_index); + for (index, paragraph) in document.paragraphs.iter().enumerate().skip(tag_index + 1) { + if matches!( + paragraph.style, + flowstate_document::PARAGRAPH_TAG + | flowstate_document::PARAGRAPH_ANALYTIC + | flowstate_document::PARAGRAPH_BLOCK + | flowstate_document::PARAGRAPH_HAT + | flowstate_document::PARAGRAPH_POCKET + ) { + break; + } + let text = paragraph_text(&document, index); + let mut offset = 0; + for run in ¶graph.runs { + let end = offset + run.len; + if run.styles.semantic == flowstate_document::SEMANTIC_CITE { + if !summary.is_empty() { + summary.push('\n'); + } + summary.push_str(&text[offset..end]); + } + offset = end; + } + if summary.contains('\n') { + break; + } + } + return Ok(summary); + } + Ok(document.text.to_string()) + } + + pub fn uses_summary_projection(&self) -> std::io::Result { + let document = self.document()?; + Ok( + document + .paragraphs + .iter() + .any(|paragraph| matches!(paragraph.style, flowstate_document::PARAGRAPH_TAG | flowstate_document::PARAGRAPH_ANALYTIC)), + ) + } +} + +fn paragraph_text(document: &flowstate_document::Document, index: usize) -> String { + document + .text + .byte_slice(flowstate_document::paragraph_byte_range(document, index)) + .to_string() +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardPoint { + pub x: f32, + pub y: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardRect { + pub min: BoardPoint, + pub max: BoardPoint, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeStyle { + pub color_rgba: u32, + pub width: f32, + pub opacity: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnnotationStroke { + pub id: StrokeId, + pub sheet_id: SheetId, + pub originator: AnnotationOriginator, + pub points: Vec, + pub style: StrokeStyle, + pub bbox: BoardRect, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Sheet { + pub id: SheetId, + pub name: String, + pub sheet_type_id: SheetTypeId, + pub cells: Vec, + pub annotations: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct SheetRecord { + id: SheetId, + name: String, + sheet_type_id: SheetTypeId, + order: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct CellRecord { + sheet_id: SheetId, + cell: Cell, + order: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FlowProjection { + pub format: FlowFormat, + pub sheets: Vec, +} + +impl Default for FlowProjection { + fn default() -> Self { + Self { + format: FlowFormat::policy_debate(), + sheets: Vec::new(), + } + } +} + +impl FlowProjection { + pub fn validate(&self) -> anyhow::Result<()> { + let mut ids = HashSet::new(); + let mut column_levels = HashMap::new(); + if !ids.insert(self.format.id) { + bail!("duplicate format id"); + } + for sheet_type in &self.format.sheet_types { + if !ids.insert(sheet_type.id) || sheet_type.columns.is_empty() { + bail!("invalid sheet type {}", sheet_type.name); + } + for (level, column) in sheet_type.columns.iter().enumerate() { + if !ids.insert(column.id) { + bail!("duplicate column id"); + } + column_levels.insert(column.id, level); + } + } + for sheet in &self.sheets { + let definition = self + .format + .sheet_type(sheet.sheet_type_id) + .with_context(|| format!("sheet {} references unknown type", sheet.name))?; + if !ids.insert(sheet.id) { + bail!("duplicate sheet id"); + } + let valid_columns: HashSet<_> = definition.columns.iter().map(|column| column.id).collect(); + let cells: HashMap<_, _> = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); + if cells.len() != sheet.cells.len() { + bail!("sheet {} contains duplicate cell ids", sheet.name); + } + for cell in &sheet.cells { + if !valid_columns.contains(&cell.column_id) { + bail!("cell references a column outside its sheet type"); + } + let _ = cell.document().context("cell contains invalid rich-text document")?; + if let Some(parent_id) = cell.parent_id { + let parent = cells.get(&parent_id).context("cell references missing parent")?; + let child_level = column_levels[&cell.column_id]; + let parent_level = column_levels[&parent.column_id]; + if child_level != parent_level + 1 { + bail!("parent-child link must connect adjacent columns"); + } + } + } + for column in &definition.columns { + let column_cells: Vec<_> = sheet.cells.iter().filter(|cell| cell.column_id == column.id).collect(); + let mut completed_parents = HashSet::new(); + let mut current_parent = None; + for cell in column_cells { + if cell.parent_id != current_parent { + if let Some(parent) = current_parent { + completed_parents.insert(parent); + } + if cell.parent_id.is_some_and(|parent| completed_parents.contains(&parent)) { + bail!("orphan or unrelated cell breaks a sibling run"); + } + current_parent = cell.parent_id; + } + } + } + if sheet.annotations.iter().any(|stroke| stroke.sheet_id != sheet.id) { + bail!("annotation references the wrong sheet"); + } + } + Ok(()) + } +} + +pub struct FlowDocument { + loro: LoroDoc, + projection: FlowProjection, + undo_manager: UndoManager, +} + +impl Clone for FlowDocument { + fn clone(&self) -> Self { + Self::from_snapshot(&self.snapshot().expect("validated flow snapshot")).expect("validated flow clone") + } +} + +impl Default for FlowDocument { + fn default() -> Self { + Self::new() + } +} impl FlowDocument { pub fn new() -> Self { - Self + Self::from_projection(FlowProjection::default()).expect("default flow projection is valid") + } + + pub fn projection(&self) -> &FlowProjection { + &self.projection + } + + pub fn from_projection(projection: FlowProjection) -> anyhow::Result { + projection.validate()?; + let loro = LoroDoc::new(); + let root = loro.get_map("flow"); + root.insert(FORMAT_KEY, postcard::to_allocvec(&projection.format)?)?; + root.insert(PROJECTION_KEY, postcard::to_allocvec(&projection)?)?; + let annotations = loro.get_map(ANNOTATIONS_MAP); + for stroke in projection.sheets.iter().flat_map(|sheet| &sheet.annotations) { + annotations.insert(&stroke.id.to_string(), postcard::to_allocvec(stroke)?)?; + } + write_all_entity_records(&loro, &projection)?; + loro.commit(); + let undo_manager = UndoManager::new(&loro); + Ok(Self { + loro, + projection, + undo_manager, + }) + } + + pub fn from_snapshot(snapshot: &[u8]) -> anyhow::Result { + let loro = LoroDoc::new(); + loro.import(snapshot)?; + let value = loro.get_map("flow").get(PROJECTION_KEY).context("Loro snapshot is missing flow projection")?; + let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { + bail!("Loro flow projection has invalid type"); + }; + let mut projection: FlowProjection = postcard::from_bytes(&bytes)?; + let immutable_format = read_immutable_format(&loro)?; + if projection.format != immutable_format { + bail!("persisted flow format definition was mutated"); + } + merge_entity_records_from_loro(&mut projection, &loro)?; + projection.validate()?; + let undo_manager = UndoManager::new(&loro); + Ok(Self { + loro, + projection, + undo_manager, + }) + } + + pub fn snapshot(&self) -> anyhow::Result> { + Ok(self.loro.export(ExportMode::Snapshot)?) + } + + /// suggestion: publish these opaque Loro update bytes through the symmetric + /// collaboration transport selected by the document-collaboration branch. + pub fn updates_since(&self, version: &VersionVector) -> anyhow::Result> { + Ok(self.loro.export(ExportMode::updates(version))?) + } + + pub fn version_vector(&self) -> VersionVector { + self.loro.oplog_vv() + } + + /// suggestion: call this for remote update bytes, then notify every UI + /// projection consumer. Flow peers are symmetric and every peer writes. + pub fn import_updates(&mut self, bytes: &[u8]) -> anyhow::Result<()> { + self.loro.import(bytes)?; + self.reload_projection() + } + + pub fn update(&mut self, change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>) -> anyhow::Result<()> { + let before = self.projection.clone(); + change(&mut self.projection)?; + if let Err(error) = self.projection.validate() { + self.projection = before; + return Err(error); + } + if self.projection.format != read_immutable_format(&self.loro)? { + self.projection = before; + bail!("flow format definitions are immutable"); + } + sync_entity_delta(&self.loro, &before, &self.projection)?; + if let Err(error) = self.commit_projection() { + self.projection = before; + return Err(error); + } + Ok(()) + } + + pub fn can_undo(&self) -> bool { + self.undo_manager.can_undo() + } + + pub fn can_redo(&self) -> bool { + self.undo_manager.can_redo() + } + + pub fn undo(&mut self) -> anyhow::Result { + let changed = self.undo_manager.undo()?; + if changed { + self.reload_projection()?; + } + Ok(changed) + } + + pub fn redo(&mut self) -> anyhow::Result { + let changed = self.undo_manager.redo()?; + if changed { + self.reload_projection()?; + } + Ok(changed) + } + + fn commit_projection(&mut self) -> anyhow::Result<()> { + if self.projection.format != read_immutable_format(&self.loro)? { + bail!("flow format definitions are immutable"); + } + let bytes = postcard::to_allocvec(&self.projection)?; + self.loro.get_map("flow").insert(PROJECTION_KEY, bytes)?; + self.loro.commit(); + Ok(()) + } + + fn reload_projection(&mut self) -> anyhow::Result<()> { + let value = self + .loro + .get_map("flow") + .get(PROJECTION_KEY) + .context("Loro snapshot is missing flow projection")?; + let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { + bail!("Loro flow projection has invalid type"); + }; + let mut projection: FlowProjection = postcard::from_bytes(&bytes)?; + if projection.format != read_immutable_format(&self.loro)? { + bail!("persisted flow format definition was mutated"); + } + merge_entity_records_from_loro(&mut projection, &self.loro)?; + projection.validate()?; + self.projection = projection; + Ok(()) + } +} + +fn read_immutable_format(loro: &LoroDoc) -> anyhow::Result { + let value = loro.get_map("flow").get(FORMAT_KEY).context("Loro snapshot is missing immutable format definition")?; + let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { + bail!("Loro immutable format definition has invalid type"); + }; + Ok(postcard::from_bytes(&bytes)?) +} + +fn sync_entity_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { + sync_sheet_delta(loro, before, after)?; + sync_cell_delta(loro, before, after)?; + sync_annotation_delta(loro, before, after) +} + +fn sync_sheet_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { + let before = sheet_records(before); + let after = sheet_records(after); + sync_record_map(loro, SHEETS_MAP, &before, &after) +} + +fn sync_cell_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { + let before = cell_records(before); + let after = cell_records(after); + sync_record_map(loro, CELLS_MAP, &before, &after) +} + +fn sync_record_map( + loro: &LoroDoc, + map_name: &str, + before: &HashMap, + after: &HashMap, +) -> anyhow::Result<()> { + let map = loro.get_map(map_name); + for id in before.keys().filter(|id| !after.contains_key(id)) { + map.delete(&id.to_string())?; + } + for (id, record) in after { + if before.get(id) != Some(record) { + map.insert(&id.to_string(), postcard::to_allocvec(record)?)?; + } + } + Ok(()) +} + +fn sync_annotation_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { + let before: HashMap<_, _> = before + .sheets + .iter() + .flat_map(|sheet| sheet.annotations.iter().map(|stroke| (stroke.id, stroke))) + .collect(); + let after: HashMap<_, _> = after + .sheets + .iter() + .flat_map(|sheet| sheet.annotations.iter().map(|stroke| (stroke.id, stroke))) + .collect(); + let map = loro.get_map(ANNOTATIONS_MAP); + for id in before.keys().filter(|id| !after.contains_key(id)) { + map.delete(&id.to_string())?; + } + for (id, stroke) in &after { + if before.get(id).is_none_or(|existing| *existing != *stroke) { + map.insert(&id.to_string(), postcard::to_allocvec(*stroke)?)?; + } + } + Ok(()) +} + +fn write_all_entity_records(loro: &LoroDoc, projection: &FlowProjection) -> anyhow::Result<()> { + for (id, record) in sheet_records(projection) { + loro.get_map(SHEETS_MAP).insert(&id.to_string(), postcard::to_allocvec(&record)?)?; + } + for (id, record) in cell_records(projection) { + loro.get_map(CELLS_MAP).insert(&id.to_string(), postcard::to_allocvec(&record)?)?; + } + Ok(()) +} + +fn sheet_records(projection: &FlowProjection) -> HashMap { + projection + .sheets + .iter() + .enumerate() + .map(|(order, sheet)| { + ( + sheet.id, + SheetRecord { + id: sheet.id, + name: sheet.name.clone(), + sheet_type_id: sheet.sheet_type_id, + order: order as u64, + }, + ) + }) + .collect() +} + +fn cell_records(projection: &FlowProjection) -> HashMap { + projection + .sheets + .iter() + .flat_map(|sheet| { + sheet.cells.iter().enumerate().map(|(order, cell)| { + ( + cell.id, + CellRecord { + sheet_id: sheet.id, + cell: cell.clone(), + order: order as u64, + }, + ) + }) + }) + .collect() +} + +fn merge_entity_records_from_loro(projection: &mut FlowProjection, loro: &LoroDoc) -> anyhow::Result<()> { + let mut sheets: Vec = read_record_map(loro, SHEETS_MAP)?; + sheets.sort_by_key(|record| (record.order, record.id)); + let existing_annotations: HashMap<_, _> = projection + .sheets + .iter_mut() + .map(|sheet| (sheet.id, std::mem::take(&mut sheet.annotations))) + .collect(); + projection.sheets = sheets + .into_iter() + .map(|record| Sheet { + id: record.id, + name: record.name, + sheet_type_id: record.sheet_type_id, + cells: Vec::new(), + annotations: existing_annotations.get(&record.id).cloned().unwrap_or_default(), + }) + .collect(); + let mut cells: Vec = read_record_map(loro, CELLS_MAP)?; + cells.sort_by_key(|record| (record.sheet_id, record.order, record.cell.id)); + for record in cells { + if let Some(sheet) = projection.sheets.iter_mut().find(|sheet| sheet.id == record.sheet_id) { + sheet.cells.push(record.cell); + } + } + merge_annotations_from_loro(projection, loro) +} + +fn read_record_map Deserialize<'de>>(loro: &LoroDoc, map_name: &str) -> anyhow::Result> { + let mut records = Vec::new(); + let mut error = None; + loro.get_map(map_name).for_each(|key, value| { + if error.is_some() { + return; + } + let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { + error = Some(anyhow::anyhow!("{map_name} record {key} has invalid Loro value type")); + return; + }; + match postcard::from_bytes(&bytes) { + Ok(record) => records.push(record), + Err(source) => error = Some(source.into()), + } + }); + error.map_or(Ok(records), Err) +} + +fn merge_annotations_from_loro(projection: &mut FlowProjection, loro: &LoroDoc) -> anyhow::Result<()> { + for sheet in &mut projection.sheets { + sheet.annotations.clear(); + } + let mut error = None; + loro.get_map(ANNOTATIONS_MAP).for_each(|key, value| { + if error.is_some() { + return; + } + let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { + error = Some(anyhow::anyhow!("annotation {key} has invalid Loro value type")); + return; + }; + let stroke: AnnotationStroke = match postcard::from_bytes(&bytes) { + Ok(stroke) => stroke, + Err(source) => { + error = Some(source.into()); + return; + }, + }; + if key != stroke.id.to_string() { + error = Some(anyhow::anyhow!("annotation key does not match stable stroke id")); + return; + } + if let Some(sheet) = projection.sheets.iter_mut().find(|sheet| sheet.id == stroke.sheet_id) { + sheet.annotations.push(stroke); + } + }); + error.map_or(Ok(()), Err) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_loro_snapshot() { + let mut document = FlowDocument::new(); + let sheet_type = document.projection().format.sheet_types[0].id; + document + .update(|projection| { + projection.sheets.push(Sheet { + id: Uuid::new_v4(), + name: "Case".into(), + sheet_type_id: sheet_type, + cells: Vec::new(), + annotations: Vec::new(), + }); + Ok(()) + }) + .unwrap(); + let restored = FlowDocument::from_snapshot(&document.snapshot().unwrap()).unwrap(); + assert_eq!(document.projection(), restored.projection()); + } + + #[test] + fn rejects_format_mutation() { + let mut document = FlowDocument::new(); + assert!( + document + .update(|projection| { + projection.format.name = "Changed".into(); + Ok(()) + }) + .is_err() + ); } } diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index 3f908663..7e95d6c3 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -1,5 +1,11 @@ mod document; +mod operations; mod persistence; -pub use document::FlowDocument; -pub use persistence::{load_flow_document, load_flow_document_or_new, save_flow_document}; +pub use document::{ + AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, Cell, CellId, ColumnDefinition, ColumnId, FlowDocument, FlowFormat, + FlowProjection, FormatId, Sheet, SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, +}; +pub use persistence::{decode as decode_flow_document, encode as encode_flow_document, load_flow_document, save_flow_document}; +pub use operations::RelativePosition; +pub use loro::VersionVector; diff --git a/crates/flowstate-flow/src/operations.rs b/crates/flowstate-flow/src/operations.rs new file mode 100644 index 00000000..078aea7d --- /dev/null +++ b/crates/flowstate-flow/src/operations.rs @@ -0,0 +1,420 @@ +use anyhow::{Context as _, bail}; +use uuid::Uuid; + +use crate::{AnnotationOriginator, AnnotationStroke, Cell, CellId, FlowDocument, Sheet, SheetId, SheetTypeId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RelativePosition { + Before, + After, +} + +impl FlowDocument { + pub fn deletion_fallback(&self, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)?; + let definition = self.projection().format.sheet_type(sheet.sheet_type_id)?; + let index = sheet.cells.iter().position(|cell| cell.id == cell_id)?; + let cell = &sheet.cells[index]; + + if let Some(previous) = sheet.cells[..index] + .iter() + .rev() + .find(|candidate| candidate.column_id == cell.column_id && candidate.parent_id == cell.parent_id) + { + return Some(previous.id); + } + if let Some(parent) = cell.parent_id { + return Some(parent); + } + + let column = definition.columns.iter().position(|column| column.id == cell.column_id)?; + let left_column = column.checked_sub(1).and_then(|index| definition.columns.get(index))?.id; + sheet + .cells + .iter() + .rev() + .find(|candidate| candidate.column_id == left_column && candidate.parent_id.is_none()) + .map(|candidate| candidate.id) + } + + pub fn create_sheet(&mut self, name: impl Into, sheet_type_id: SheetTypeId) -> anyhow::Result { + let id = Uuid::new_v4(); + let name = name.into(); + self.update(|projection| { + projection.format.sheet_type(sheet_type_id).context("unknown sheet type")?; + projection.sheets.push(Sheet { + id, + name, + sheet_type_id, + cells: Vec::new(), + annotations: Vec::new(), + }); + Ok(()) + })?; + Ok(id) + } + + pub fn rename_sheet(&mut self, sheet_id: SheetId, name: impl Into) -> anyhow::Result<()> { + let name = name.into(); + self.update(|projection| { + projection + .sheets + .iter_mut() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .name = name; + Ok(()) + }) + } + + pub fn delete_sheet(&mut self, sheet_id: SheetId) -> anyhow::Result<()> { + self.update(|projection| { + let before = projection.sheets.len(); + projection.sheets.retain(|sheet| sheet.id != sheet_id); + if projection.sheets.len() == before { + bail!("unknown sheet"); + } + Ok(()) + }) + } + + pub fn move_sheet(&mut self, sheet_id: SheetId, target_index: usize) -> anyhow::Result<()> { + self.update(|projection| { + let source = projection.sheets.iter().position(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let sheet = projection.sheets.remove(source); + projection.sheets.insert(target_index.min(projection.sheets.len()), sheet); + Ok(()) + }) + } + + pub fn add_plain_cell( + &mut self, + sheet_id: SheetId, + column_index: usize, + parent_id: Option, + insertion_index: Option, + ) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let definition = self.projection().format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + let column_id = definition.columns.get(column_index).context("column index out of range")?.id; + let mut cell = Cell::plain(column_id)?; + cell.parent_id = parent_id; + let id = cell.id; + self.update(|projection| { + let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + sheet.cells.insert(insertion_index.unwrap_or(sheet.cells.len()).min(sheet.cells.len()), cell); + Ok(()) + })?; + Ok(id) + } + + pub fn add_sibling(&mut self, sheet_id: SheetId, cell_id: CellId, position: RelativePosition) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let index = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; + let source = &sheet.cells[index]; + let definition = self.projection().format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + let column = definition.columns.iter().position(|column| column.id == source.column_id).context("unknown column")?; + let insertion = match position { + RelativePosition::Before => index, + RelativePosition::After => index + 1, + }; + self.add_plain_cell(sheet_id, column, source.parent_id, Some(insertion)) + } + + pub fn add_response(&mut self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let parent = sheet.cells.iter().find(|cell| cell.id == parent_id).context("unknown cell")?; + let definition = self.projection().format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + let parent_column = definition.columns.iter().position(|column| column.id == parent.column_id).context("unknown column")?; + let child_column = parent_column + 1; + if child_column >= definition.columns.len() { + bail!("rightmost cells cannot receive responses"); + } + let insertion = sheet + .cells + .iter() + .position(|cell| cell.parent_id == Some(parent_id)) + .unwrap_or(sheet.cells.len()); + self.add_plain_cell(sheet_id, child_column, Some(parent_id), Some(insertion)) + } + + pub fn delete_cell(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { + self.update(|projection| { + let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let before = sheet.cells.len(); + sheet.cells.retain(|cell| cell.id != cell_id); + if sheet.cells.len() == before { + bail!("unknown cell"); + } + for cell in &mut sheet.cells { + if cell.parent_id == Some(cell_id) { + cell.parent_id = None; + } + } + Ok(()) + }) + } + + pub fn strike_cell(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { + self.update(|projection| { + let cell = projection + .sheets + .iter_mut() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .cells + .iter_mut() + .find(|cell| cell.id == cell_id) + .context("unknown cell")?; + let mut document = cell.document()?; + let paragraphs = std::sync::Arc::make_mut(&mut document.paragraphs); + for paragraph in paragraphs { + for run in &mut paragraph.runs { + run.styles.strikethrough = true; + } + } + cell.document_bytes = flowstate_document::db8_bytes(&document)?; + Ok(()) + }) + } + + pub fn replace_cell_document( + &mut self, + sheet_id: SheetId, + cell_id: CellId, + document: &flowstate_document::Document, + ) -> anyhow::Result<()> { + let bytes = flowstate_document::db8_bytes(document)?; + self.update(|projection| { + projection + .sheets + .iter_mut() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .cells + .iter_mut() + .find(|cell| cell.id == cell_id) + .context("unknown cell")? + .document_bytes = bytes; + Ok(()) + }) + } + + pub fn move_cell( + &mut self, + sheet_id: SheetId, + cell_id: CellId, + target_column: usize, + target_index: usize, + new_parent: Option, + ) -> anyhow::Result<()> { + self.update(|projection| { + let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let definition = projection.format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + let column_id = definition.columns.get(target_column).context("column index out of range")?.id; + let source = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; + let mut cell = sheet.cells.remove(source); + let source_column = definition + .columns + .iter() + .position(|column| column.id == cell.column_id) + .context("unknown source column")?; + let delta = target_column as isize - source_column as isize; + cell.column_id = column_id; + cell.parent_id = new_parent; + let mut moving_parents = vec![cell_id]; + while let Some(parent_id) = moving_parents.pop() { + let child_ids: Vec<_> = sheet + .cells + .iter() + .filter(|descendant| descendant.parent_id == Some(parent_id)) + .map(|descendant| descendant.id) + .collect(); + for child_id in child_ids { + let descendant = sheet.cells.iter_mut().find(|candidate| candidate.id == child_id).context("missing descendant")?; + let descendant_column = definition + .columns + .iter() + .position(|column| column.id == descendant.column_id) + .context("unknown descendant column")?; + let destination = descendant_column as isize + delta; + if let Ok(destination) = usize::try_from(destination) + && let Some(column) = definition.columns.get(destination) + { + descendant.column_id = column.id; + moving_parents.push(child_id); + } else { + descendant.parent_id = None; + } + } + } + sheet.cells.insert(target_index.min(sheet.cells.len()), cell); + Ok(()) + }) + } + + pub fn add_annotation(&mut self, sheet_id: SheetId, stroke: AnnotationStroke) -> anyhow::Result<()> { + self.update(|projection| { + if stroke.sheet_id != sheet_id { + bail!("annotation sheet id mismatch"); + } + projection + .sheets + .iter_mut() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .annotations + .push(stroke); + Ok(()) + }) + } + + pub fn clear_annotations(&mut self, sheet_id: SheetId, originator: &AnnotationOriginator) -> anyhow::Result<()> { + self.update(|projection| { + let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + sheet.annotations.retain(|stroke| &stroke.originator != originator); + Ok(()) + }) + } + + pub fn delete_annotation(&mut self, sheet_id: SheetId, stroke_id: uuid::Uuid, originator: &AnnotationOriginator) -> anyhow::Result { + let mut removed = false; + self.update(|projection| { + let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let before = sheet.annotations.len(); + sheet.annotations.retain(|stroke| stroke.id != stroke_id || &stroke.originator != originator); + removed = sheet.annotations.len() != before; + Ok(()) + })?; + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BoardPoint, BoardRect, StrokeStyle}; + + fn document_with_sheet() -> (FlowDocument, SheetId) { + let mut document = FlowDocument::new(); + let sheet_type = document.projection().format.sheet_types[0].id; + let sheet = document.create_sheet("Case", sheet_type).unwrap(); + (document, sheet) + } + + #[test] + fn deleting_parent_orphans_children() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let child = document.add_response(sheet, parent).unwrap(); + document.delete_cell(sheet, parent).unwrap(); + assert_eq!(document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().parent_id, None); + } + + #[test] + fn deletion_fallback_prefers_previous_sibling_then_parent() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let first = document.add_plain_cell(sheet, 1, Some(parent), None).unwrap(); + let second = document.add_plain_cell(sheet, 1, Some(parent), None).unwrap(); + assert_eq!(document.deletion_fallback(sheet, second), Some(first)); + assert_eq!(document.deletion_fallback(sheet, first), Some(parent)); + } + + #[test] + fn deletion_fallback_for_orphan_prefers_same_column_then_left_column() { + let (mut document, sheet) = document_with_sheet(); + let left_first = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let left_last = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let orphan_first = document.add_plain_cell(sheet, 1, None, None).unwrap(); + let orphan_second = document.add_plain_cell(sheet, 1, None, None).unwrap(); + assert_eq!(document.deletion_fallback(sheet, orphan_second), Some(orphan_first)); + assert_eq!(document.deletion_fallback(sheet, orphan_first), Some(left_last)); + assert_eq!(document.deletion_fallback(sheet, left_first), None); + } + + #[test] + fn moving_parent_away_orphans_invalid_children() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let child = document.add_response(sheet, parent).unwrap(); + document.move_cell(sheet, parent, 6, 0, None).unwrap(); + assert_eq!(document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().parent_id, None); + } + + #[test] + fn moving_parent_carries_valid_descendants() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let child = document.add_response(sheet, parent).unwrap(); + document.move_cell(sheet, parent, 1, 0, None).unwrap(); + let sheet = &document.projection().sheets[0]; + let definition = &document.projection().format.sheet_types[0]; + assert_eq!(sheet.cells.iter().find(|cell| cell.id == child).unwrap().column_id, definition.columns[2].id); + } + + #[test] + fn undo_and_redo_restore_projection() { + let (mut document, sheet) = document_with_sheet(); + document.add_plain_cell(sheet, 0, None, None).unwrap(); + assert_eq!(document.projection().sheets[0].cells.len(), 1); + assert!(document.undo().unwrap()); + assert!(document.projection().sheets[0].cells.is_empty()); + assert!(document.redo().unwrap()); + assert_eq!(document.projection().sheets[0].cells.len(), 1); + } + + #[test] + fn concurrent_annotation_adds_merge_additively() { + let (base, sheet) = document_with_sheet(); + let snapshot = base.snapshot().unwrap(); + let mut one = FlowDocument::from_snapshot(&snapshot).unwrap(); + let mut two = FlowDocument::from_snapshot(&snapshot).unwrap(); + one.add_annotation(sheet, test_stroke(sheet, "one")).unwrap(); + two.add_annotation(sheet, test_stroke(sheet, "two")).unwrap(); + let one_updates = one.updates_since(&two.version_vector()).unwrap(); + let two_updates = two.updates_since(&one.version_vector()).unwrap(); + one.import_updates(&two_updates).unwrap(); + two.import_updates(&one_updates).unwrap(); + assert_eq!(one.projection().sheets[0].annotations.len(), 2); + assert_eq!(two.projection().sheets[0].annotations.len(), 2); + } + + #[test] + fn concurrent_cell_adds_merge_by_stable_id() { + let (base, sheet) = document_with_sheet(); + let snapshot = base.snapshot().unwrap(); + let mut one = FlowDocument::from_snapshot(&snapshot).unwrap(); + let mut two = FlowDocument::from_snapshot(&snapshot).unwrap(); + one.add_plain_cell(sheet, 0, None, None).unwrap(); + two.add_plain_cell(sheet, 0, None, None).unwrap(); + exchange_updates(&mut one, &mut two); + assert_eq!(one.projection().sheets[0].cells.len(), 2); + assert_eq!(two.projection().sheets[0].cells.len(), 2); + } + + fn exchange_updates(one: &mut FlowDocument, two: &mut FlowDocument) { + let one_updates = one.updates_since(&two.version_vector()).unwrap(); + let two_updates = two.updates_since(&one.version_vector()).unwrap(); + one.import_updates(&two_updates).unwrap(); + two.import_updates(&one_updates).unwrap(); + } + + fn test_stroke(sheet_id: SheetId, originator: &str) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::new_v4(), + sheet_id, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 2.0, y: 2.0 }], + style: StrokeStyle { + color_rgba: 0xff00_0000, + width: 2.0, + opacity: 0.5, + }, + bbox: BoardRect { + min: BoardPoint { x: 0.0, y: 0.0 }, + max: BoardPoint { x: 2.0, y: 2.0 }, + }, + } + } +} diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index e6447b8c..470c0377 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -1,21 +1,82 @@ use std::fs; +use std::io::{self, Cursor, Read as _}; use std::path::Path; use crate::document::FlowDocument; -pub fn load_flow_document(_path: impl AsRef) -> anyhow::Result { - Ok(FlowDocument::new()) -} +const MAGIC: &[u8; 8] = b"FLOWFL0\0"; +const VERSION: u32 = 1; -pub fn load_flow_document_or_new(_path: impl AsRef) -> FlowDocument { - FlowDocument::new() +pub fn load_flow_document(path: impl AsRef) -> anyhow::Result { + decode(&fs::read(path)?) } -pub fn save_flow_document(_path: impl AsRef, _document: &FlowDocument) -> anyhow::Result<()> { - let path = _path.as_ref(); +pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> anyhow::Result<()> { + let path = path.as_ref(); if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) { fs::create_dir_all(parent)?; } - fs::write(path, b"{}")?; + fs::write(path, encode(document)?)?; Ok(()) } + +pub fn encode(document: &FlowDocument) -> anyhow::Result> { + let snapshot = document.snapshot()?; + let compressed = zstd::stream::encode_all(snapshot.as_slice(), 3)?; + let mut bytes = Vec::with_capacity(MAGIC.len() + 8 + compressed.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.extend_from_slice(&(compressed.len() as u64).to_le_bytes()); + bytes.extend_from_slice(&compressed); + Ok(bytes) +} + +pub fn decode(bytes: &[u8]) -> anyhow::Result { + let mut cursor = Cursor::new(bytes); + let mut magic = [0; 8]; + cursor.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 signature").into()); + } + let version = read_u32(&mut cursor)?; + if version != VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported .fl0 version {version}")).into()); + } + let payload_len = read_u64(&mut cursor)? as usize; + if payload_len != bytes.len().saturating_sub(cursor.position() as usize) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 payload length").into()); + } + let mut compressed = Vec::with_capacity(payload_len); + cursor.read_to_end(&mut compressed)?; + let snapshot = zstd::stream::decode_all(compressed.as_slice())?; + FlowDocument::from_snapshot(&snapshot) +} + +fn read_u32(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 4]; + cursor.read_exact(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_u64(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 8]; + cursor.read_exact(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_corruption() { + assert!(decode(b"{}").is_err()); + } + + #[test] + fn round_trips() { + let document = FlowDocument::new(); + let restored = decode(&encode(&document).unwrap()).unwrap(); + assert_eq!(document.projection(), restored.projection()); + } +} diff --git a/crates/flowstate/assets/icons/remove-formatting.svg b/crates/flowstate/assets/icons/remove-formatting.svg new file mode 100644 index 00000000..c9b41f22 --- /dev/null +++ b/crates/flowstate/assets/icons/remove-formatting.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/src/app.rs b/crates/flowstate/src/app.rs index 6f298b17..e5e598c0 100644 --- a/crates/flowstate/src/app.rs +++ b/crates/flowstate/src/app.rs @@ -345,6 +345,7 @@ impl AssetSource for AppAssets { "icons/save.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/save.svg")))), "icons/bold.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/bold.svg")))), "icons/eraser.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/eraser.svg")))), + "icons/remove-formatting.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/remove-formatting.svg")))), "icons/highlighter.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/highlighter.svg")))), "icons/mark-card.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/mark-card.svg")))), "icons/shrink.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/shrink.svg")))), @@ -388,6 +389,9 @@ impl AssetSource for AppAssets { if "icons/eraser.svg".starts_with(path) { assets.push("icons/eraser.svg".into()); } + if "icons/remove-formatting.svg".starts_with(path) { + assets.push("icons/remove-formatting.svg".into()); + } if "icons/highlighter.svg".starts_with(path) { assets.push("icons/highlighter.svg".into()); } diff --git a/crates/flowstate/src/commands.rs b/crates/flowstate/src/commands.rs index c0af4303..abdc6e71 100644 --- a/crates/flowstate/src/commands.rs +++ b/crates/flowstate/src/commands.rs @@ -102,6 +102,9 @@ pub enum CommandId { SwitchToTab9, SwitchToTab10, ScrollToParagraph, + FlowAddSiblingAbove, + FlowDeleteSelected, + FlowStrike, } #[derive(Clone, Copy, Debug)] @@ -211,7 +214,7 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::SendToSpeechDocumentEnd, "Send to Speech Document End", APP, &["alt-`"]), CommandSpec::new(CommandId::CondenseSelection, "Condense Selection", APP, &["f3"]), CommandSpec::new(CommandId::MarkCard, "Mark Card", APP, &["ctrl-m"]), - CommandSpec::new(CommandId::CondensedSelection, "Shrink", APP, &[]), + CommandSpec::new(CommandId::CondensedSelection, "Shrink", APP, &["ctrl-8"]), CommandSpec::new(CommandId::ToggleSpeechDocument, "Toggle Speech Document", APP, &[]), CommandSpec::new(CommandId::ExportFormat, "Export Format", APP, &[]), CommandSpec::new(CommandId::ExportSend, "Export Send", APP, &[]), @@ -227,6 +230,9 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::SwitchToTab9, "Switch to Tab 9", APP, &["alt-9"]), CommandSpec::new(CommandId::SwitchToTab10, "Switch to Tab 10", APP, &["alt-0"]), CommandSpec::new(CommandId::ScrollToParagraph, "Scroll to Paragraph", APP, &[]), + CommandSpec::new(CommandId::FlowAddSiblingAbove, "Flow: Add Sibling Above", APP, &["alt-enter"]), + CommandSpec::new(CommandId::FlowDeleteSelected, "Flow: Delete Selected", APP, &["cmd-delete"]), + CommandSpec::new(CommandId::FlowStrike, "Flow: Strike", APP, &["cmd-shift-x", "ctrl-shift-x"]), ]; #[hotpath::measure] diff --git a/crates/flowstate/src/commands/keymap.rs b/crates/flowstate/src/commands/keymap.rs index 00a0d8cd..7e239f19 100644 --- a/crates/flowstate/src/commands/keymap.rs +++ b/crates/flowstate/src/commands/keymap.rs @@ -175,6 +175,7 @@ pub(crate) fn action_for_command(command: CommandId) -> Option> | CommandId::SwitchToTab9 | CommandId::SwitchToTab10 | CommandId::ScrollToParagraph => return None, + CommandId::FlowAddSiblingAbove | CommandId::FlowDeleteSelected | CommandId::FlowStrike => return None, }; Some(action) } diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 0c6f208c..6812ca49 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,28 +1,307 @@ use std::path::PathBuf; +use std::collections::HashSet; -use gpui::{App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Task, Window, div, prelude::*}; +use flowstate_flow::{ + AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowDocument, RelativePosition, SheetId, StrokeStyle, VersionVector, +}; +use gpui::{ + AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, + KeyDownEvent, KeyUpEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Render, SharedString, Subscription, Task, Window, canvas, div, point, + prelude::*, px, rgba, ScrollHandle, +}; +use gpui_component::ActiveTheme as _; +use gpui_component::PixelsExt as _; +use gpui_component::scroll::{Scrollbar, ScrollbarShow}; + +use crate::rich_text_element::RichTextEditor; + +#[derive(Clone, Debug)] +pub enum FlowEditorEvent { + Changed, + ActiveCellChanged(Option), + ActiveSheetChanged(Option), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AnnotationTool { + #[default] + None, + Marker, + Eraser, +} + +#[derive(Clone)] +struct FlowCellDrag { + cell_id: CellId, +} + +struct FlowCellDragPreview; + +impl Render for FlowCellDragPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .child("Move argument") + } +} pub struct FlowEditor { + document: FlowDocument, path: Option, dirty: bool, + active_sheet: Option, + active_cell: Option, + collapsed_outline_items: HashSet, + annotation_tool: AnnotationTool, + hidden_annotation_sheets: HashSet, + hidden_annotation_originators: HashSet, + local_annotation_originator: AnnotationOriginator, + drawing_points: Vec, + cell_editors: std::collections::HashMap>, + cell_editor_subscriptions: Vec, + pending_cell_drop: Option<(CellId, RelativePosition)>, + cell_bounds: std::collections::HashMap>, + board_scroll: ScrollHandle, + board_zoom: f32, + viewport_origin: BoardPoint, + space_pan_armed: bool, + pan_drag_anchor: Option<(gpui::Point, gpui::Point)>, focus_handle: FocusHandle, } impl FlowEditor { - pub fn new_with_path(_document: flowstate_flow::FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { + pub fn new_with_path(document: FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { + let active_sheet = document.projection().sheets.first().map(|sheet| sheet.id); + let collapsed_outline_items = document + .projection() + .sheets + .iter() + .flat_map(|sheet| std::iter::once(sheet.id).chain(sheet.cells.iter().map(|cell| cell.id))) + .collect(); Self { + document, path, dirty: false, + active_sheet, + active_cell: None, + collapsed_outline_items, + annotation_tool: AnnotationTool::None, + hidden_annotation_sheets: HashSet::new(), + hidden_annotation_originators: HashSet::new(), + // suggestion: replace this opaque local value with the identity source + // selected by the document-collaboration implementation. + local_annotation_originator: AnnotationOriginator("local".into()), + drawing_points: Vec::new(), + cell_editors: std::collections::HashMap::new(), + cell_editor_subscriptions: Vec::new(), + pending_cell_drop: None, + cell_bounds: std::collections::HashMap::new(), + board_scroll: ScrollHandle::new(), + board_zoom: 1.0, + viewport_origin: BoardPoint::default(), + space_pan_armed: false, + pan_drag_anchor: None, focus_handle: cx.focus_handle(), } } pub fn blank(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(flowstate_flow::FlowDocument::new(), None, window, cx) + Self::new_with_path(FlowDocument::new(), None, window, cx) + } + + pub fn document(&self) -> &FlowDocument { + &self.document + } + + pub fn version_vector(&self) -> VersionVector { + self.document.version_vector() + } + + pub fn collaboration_updates_since(&self, version: &VersionVector) -> anyhow::Result> { + self.document.updates_since(version) + } + + pub fn import_collaboration_updates(&mut self, bytes: &[u8], cx: &mut Context) -> anyhow::Result<()> { + self.document.import_updates(bytes)?; + self.sync_cell_editors(cx); + self.changed(self.active_cell, cx); + Ok(()) + } + + pub fn active_sheet(&self) -> Option { + self.active_sheet + } + + pub fn active_cell(&self) -> Option { + self.active_cell + } + + pub fn annotation_tool(&self) -> AnnotationTool { + self.annotation_tool + } + + pub fn annotations_visible(&self) -> bool { + self.active_sheet.is_some_and(|sheet| !self.hidden_annotation_sheets.contains(&sheet)) + } + + pub fn board_zoom(&self) -> f32 { + self.board_zoom + } + + /// suggestion: wire future zoom controls here. Annotation and board-space + /// transforms already consume this value even though no zoom UI exists yet. + pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut Context) { + self.board_zoom = zoom.clamp(0.25, 4.0); + cx.notify(); + } + + pub fn visible_board_rect(&self) -> BoardRect { + let bounds = self.board_scroll.bounds(); + let offset = self.board_scroll.offset(); + let scroll_x = -offset.x.as_f32(); + let scroll_y = -offset.y.as_f32(); + BoardRect { + min: BoardPoint { + x: scroll_x, + y: scroll_y, + }, + max: BoardPoint { + x: scroll_x + bounds.size.width.as_f32() / self.board_zoom, + y: scroll_y + bounds.size.height.as_f32() / self.board_zoom, + }, + } + } + + pub fn set_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { + self.annotation_tool = tool; + cx.notify(); + } + + pub fn toggle_annotations_visible(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if !self.hidden_annotation_sheets.remove(&sheet) { + self.hidden_annotation_sheets.insert(sheet); + } + cx.notify(); + } + + pub fn set_originator_annotations_hidden(&mut self, originator: AnnotationOriginator, hidden: bool, cx: &mut Context) { + if hidden { + self.hidden_annotation_originators.insert(originator); + } else { + self.hidden_annotation_originators.remove(&originator); + } + cx.notify(); + } + + pub fn clear_annotations(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if self.document.clear_annotations(sheet, &self.local_annotation_originator).is_ok() { + self.changed(self.active_cell, cx); + } } - pub fn load_or_new(path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(flowstate_flow::load_flow_document_or_new(&path), Some(path), window, cx) + fn annotation_point(&self, position: gpui::Point) -> BoardPoint { + let offset = self.board_scroll.offset(); + BoardPoint { + x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom - offset.x.as_f32(), + y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom - offset.y.as_f32(), + } + } + + fn begin_annotation(&mut self, position: gpui::Point, cx: &mut Context) { + match self.annotation_tool { + AnnotationTool::Marker => { + self.drawing_points.clear(); + self.drawing_points.push(self.annotation_point(position)); + }, + AnnotationTool::Eraser => self.erase_at(self.annotation_point(position), cx), + AnnotationTool::None => {}, + } + cx.notify(); + } + + fn continue_annotation(&mut self, position: gpui::Point, cx: &mut Context) { + if self.annotation_tool != AnnotationTool::Marker || self.drawing_points.is_empty() { + return; + } + let point = self.annotation_point(position); + let should_append = self.drawing_points.last().is_none_or(|last| { + let dx = point.x - last.x; + let dy = point.y - last.y; + dx * dx + dy * dy >= 4.0 + }); + if should_append { + self.drawing_points.push(point); + cx.notify(); + } + } + + fn finish_annotation(&mut self, cx: &mut Context) { + let Some(sheet_id) = self.active_sheet else { + self.drawing_points.clear(); + return; + }; + if self.drawing_points.len() < 2 { + self.drawing_points.clear(); + return; + } + let points = simplify_stroke(&std::mem::take(&mut self.drawing_points), 1.5); + let bbox = stroke_bbox(&points); + let stroke = AnnotationStroke { + id: uuid::Uuid::new_v4(), + sheet_id, + originator: self.local_annotation_originator.clone(), + points, + style: StrokeStyle { + color_rgba: 0xfff5_9e0b, + width: 4.0, + opacity: 0.55, + }, + bbox, + }; + if self.document.add_annotation(sheet_id, stroke).is_ok() { + self.changed(self.active_cell, cx); + } + } + + fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { + let Some(sheet) = self + .active_sheet + .and_then(|sheet_id| self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)) + else { + return; + }; + let radius = 10.0; + let touched = sheet + .annotations + .iter() + .find(|stroke| { + stroke.originator == self.local_annotation_originator + && point.x >= stroke.bbox.min.x - radius + && point.x <= stroke.bbox.max.x + radius + && point.y >= stroke.bbox.min.y - radius + && point.y <= stroke.bbox.max.y + radius + && stroke.points.windows(2).any(|segment| segment_distance(point, segment[0], segment[1]) <= radius) + }) + .map(|stroke| stroke.id); + if let Some(stroke_id) = touched + && self + .document + .delete_annotation(sheet.id, stroke_id, &self.local_annotation_originator) + .is_ok_and(|removed| removed) + { + self.changed(self.active_cell, cx); + } } pub fn document_path(&self) -> Option<&PathBuf> { @@ -38,11 +317,386 @@ impl FlowEditor { self.dirty } + pub fn activate_sheet(&mut self, sheet_id: SheetId, cx: &mut Context) { + if self.document.projection().sheets.iter().any(|sheet| sheet.id == sheet_id) { + self.active_sheet = Some(sheet_id); + self.active_cell = None; + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet_id))); + cx.notify(); + } + } + + pub fn activate_cell(&mut self, cell_id: CellId, cx: &mut Context) { + let sheet_id = self + .document + .projection() + .sheets + .iter() + .find(|sheet| sheet.cells.iter().any(|cell| cell.id == cell_id)) + .map(|sheet| sheet.id); + if let Some(sheet_id) = sheet_id { + self.ensure_cell_editor(cell_id, cx); + self.active_sheet = Some(sheet_id); + self.active_cell = Some(cell_id); + self.scroll_cell_into_view(cell_id); + cx.emit(FlowEditorEvent::ActiveCellChanged(Some(cell_id))); + cx.notify(); + } + } + + pub fn outline_item_expanded(&self, id: uuid::Uuid) -> bool { + !self.collapsed_outline_items.contains(&id) + } + + pub fn toggle_outline_item(&mut self, id: uuid::Uuid, cx: &mut Context) { + if !self.collapsed_outline_items.remove(&id) { + self.collapsed_outline_items.insert(id); + } + cx.notify(); + } + + pub fn add_response(&mut self, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { + return; + }; + if let Ok(id) = self.document.add_response(sheet, cell) { + self.collapsed_outline_items.insert(id); + self.ensure_cell_editor(id, cx); + self.changed(Some(id), cx); + } + } + + pub fn add_first_argument(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if let Ok(id) = self.document.add_plain_cell(sheet, 0, None, None) { + self.collapsed_outline_items.insert(id); + self.ensure_cell_editor(id, cx); + self.changed(Some(id), cx); + } + } + + pub fn create_sheet(&mut self, cx: &mut Context) { + self.create_sheet_of_type(0, cx); + } + + pub fn create_sheet_of_type(&mut self, sheet_type_index: usize, cx: &mut Context) { + let Some(sheet_type) = self.document.projection().format.sheet_types.get(sheet_type_index) else { + return; + }; + let name = format!("Sheet {}", self.document.projection().sheets.len() + 1); + if let Ok(id) = self.document.create_sheet(name, sheet_type.id) { + self.collapsed_outline_items.insert(id); + self.active_sheet = Some(id); + self.active_cell = None; + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(id))); + cx.notify(); + } + } + + pub fn rename_active_sheet(&mut self, name: impl Into, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if self.document.rename_sheet(sheet, name).is_ok() { + self.changed(self.active_cell, cx); + } + } + + pub fn delete_active_sheet(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if self.document.delete_sheet(sheet).is_ok() { + self.collapsed_outline_items.remove(&sheet); + self.active_sheet = self.document.projection().sheets.first().map(|sheet| sheet.id); + self.active_cell = None; + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.emit(FlowEditorEvent::ActiveSheetChanged(self.active_sheet)); + cx.emit(FlowEditorEvent::ActiveCellChanged(None)); + cx.notify(); + } + } + + pub fn move_active_sheet(&mut self, direction: isize, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + let Some(index) = self.document.projection().sheets.iter().position(|candidate| candidate.id == sheet) else { + return; + }; + let target = index.saturating_add_signed(direction).min(self.document.projection().sheets.len().saturating_sub(1)); + if target != index && self.document.move_sheet(sheet, target).is_ok() { + self.changed(self.active_cell, cx); + } + } + + fn update_cell_drop(&mut self, target: CellId, position: RelativePosition, cx: &mut Context) { + self.pending_cell_drop = Some((target, position)); + cx.notify(); + } + + fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { + let Some((target, position)) = self.pending_cell_drop.take() else { + return; + }; + if dragged == target { + return; + } + let Some(sheet_id) = self.active_sheet else { + return; + }; + let Some(sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { + return; + }; + let Some(target_index) = sheet.cells.iter().position(|cell| cell.id == target) else { + return; + }; + let target_cell = &sheet.cells[target_index]; + let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { + return; + }; + let Some(column_index) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { + return; + }; + let insertion_index = match position { + RelativePosition::Before => target_index, + RelativePosition::After => target_index + 1, + }; + if self + .document + .move_cell(sheet_id, dragged, column_index, insertion_index, target_cell.parent_id) + .is_ok() + { + self.changed(Some(dragged), cx); + } + } + + fn set_cell_bounds(&mut self, cell_id: CellId, bounds: Bounds, cx: &mut Context) { + if self.cell_bounds.get(&cell_id) != Some(&bounds) { + self.cell_bounds.insert(cell_id, bounds); + cx.notify(); + } + } + + fn scroll_cell_into_view(&self, cell_id: CellId) { + let Some(cell) = self.cell_bounds.get(&cell_id) else { + return; + }; + let viewport = self.board_scroll.bounds(); + let mut offset = self.board_scroll.offset(); + if cell.left() < viewport.left() { + offset.x += viewport.left() - cell.left(); + } else if cell.right() > viewport.right() { + offset.x -= cell.right() - viewport.right(); + } + if cell.top() < viewport.top() { + offset.y += viewport.top() - cell.top(); + } else if cell.bottom() > viewport.bottom() { + offset.y -= cell.bottom() - viewport.bottom(); + } + self.board_scroll.set_offset(offset); + } + + fn set_viewport_origin(&mut self, origin: gpui::Point) { + self.viewport_origin = BoardPoint { + x: origin.x.as_f32(), + y: origin.y.as_f32(), + }; + } + + fn begin_space_pan(&mut self, position: gpui::Point, cx: &mut Context) { + if self.space_pan_armed { + self.pan_drag_anchor = Some((position, self.board_scroll.offset())); + cx.notify(); + } + } + + fn continue_space_pan(&mut self, position: gpui::Point) { + if let Some((start, offset)) = self.pan_drag_anchor { + self.board_scroll.set_offset(offset + (position - start)); + } + } + + fn finish_space_pan(&mut self, cx: &mut Context) { + self.pan_drag_anchor = None; + cx.notify(); + } + + pub fn add_sibling(&mut self, position: RelativePosition, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { + return; + }; + if let Ok(id) = self.document.add_sibling(sheet, cell, position) { + self.collapsed_outline_items.insert(id); + self.ensure_cell_editor(id, cx); + self.changed(Some(id), cx); + } + } + + pub fn active_cell_is_empty(&self) -> bool { + self + .active_sheet + .zip(self.active_cell) + .and_then(|(sheet_id, cell_id)| { + self + .document + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id)? + .cells + .iter() + .find(|cell| cell.id == cell_id) + }) + .is_some_and(|cell| cell.is_empty().unwrap_or(false)) + } + + pub fn delete_selected(&mut self, window: &mut Window, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { + return; + }; + let fallback = self.document.deletion_fallback(sheet, cell); + if self.document.delete_cell(sheet, cell).is_ok() { + self.cell_editors.remove(&cell); + if let Some(fallback) = fallback { + self.ensure_cell_editor(fallback, cx); + self.changed(Some(fallback), cx); + if let Some(editor) = self.cell_editors.get(&fallback) { + editor.read(cx).focus_handle(cx).focus(window); + } + self.scroll_cell_into_view(fallback); + } else { + self.changed(None, cx); + } + } + } + + pub fn strike_selected(&mut self, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { + return; + }; + if self.document.strike_cell(sheet, cell).is_ok() { + self.sync_cell_editors(cx); + self.changed(Some(cell), cx); + } + } + + pub fn can_undo(&self) -> bool { + self.document.can_undo() + } + + pub fn can_redo(&self) -> bool { + self.document.can_redo() + } + + pub fn undo(&mut self, cx: &mut Context) { + if self.document.undo().is_ok_and(|changed| changed) { + self.sync_cell_editors(cx); + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + } + + pub fn redo(&mut self, cx: &mut Context) { + if self.document.redo().is_ok_and(|changed| changed) { + self.sync_cell_editors(cx); + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + } + + fn changed(&mut self, active_cell: Option, cx: &mut Context) { + self.active_cell = active_cell; + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.emit(FlowEditorEvent::ActiveCellChanged(active_cell)); + cx.notify(); + } + + fn ensure_cell_editor(&mut self, cell_id: CellId, cx: &mut Context) { + if self.cell_editors.contains_key(&cell_id) { + return; + } + let Some((sheet_id, document, uses_summary_projection)) = self.document.projection().sheets.iter().find_map(|sheet| { + sheet + .cells + .iter() + .find(|cell| cell.id == cell_id) + .and_then(|cell| cell.document().ok().map(|document| (document, cell.uses_summary_projection().unwrap_or(false)))) + .map(|(document, uses_summary_projection)| (sheet.id, document, uses_summary_projection)) + }) else { + return; + }; + let editor = cx.new(|cx| { + let mut editor = RichTextEditor::new_with_path(document, None, cx); + editor.set_invisibility_mode(uses_summary_projection, cx); + editor.update_config(|config| config.allow_paragraph_breaks = false, cx); + editor + }); + let subscription = cx.observe(&editor, move |flow, editor, cx| { + let document = editor.read(cx).document().clone(); + let Ok(bytes) = flowstate_document::db8_bytes(&document) else { + return; + }; + let unchanged = flow + .document + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id) + .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) + .is_some_and(|cell| cell.document_bytes == bytes); + if unchanged { + return; + } + if flow.document.replace_cell_document(sheet_id, cell_id, &document).is_ok() { + flow.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + }); + self.cell_editors.insert(cell_id, editor); + self.cell_editor_subscriptions.push(subscription); + } + + fn sync_cell_editors(&mut self, cx: &mut Context) { + let cells: std::collections::HashMap<_, _> = self + .document + .projection() + .sheets + .iter() + .flat_map(|sheet| sheet.cells.iter().filter_map(|cell| cell.document().ok().map(|document| (cell.id, document)))) + .collect(); + self.cell_editors.retain(|id, _| cells.contains_key(id)); + for (cell_id, editor) in &self.cell_editors { + if let Some(document) = cells.get(cell_id) { + let current = flowstate_document::db8_bytes(editor.read(cx).document()).ok(); + let desired = flowstate_document::db8_bytes(document).ok(); + if current != desired { + let document = document.clone(); + editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(document, cx)); + } + } + } + if let Some(active) = self.active_cell + && cells.contains_key(&active) + { + self.ensure_cell_editor(active, cx); + } + } + pub fn save(&mut self, cx: &mut Context) -> Task> { let Some(path) = self.path.clone() else { - return cx.background_executor().spawn(async { - Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) - }); + return cx + .background_executor() + .spawn(async { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) }); }; self.save_to_path(path, cx) } @@ -53,30 +707,307 @@ impl FlowEditor { } fn save_to_path(&mut self, path: PathBuf, cx: &mut Context) -> Task> { + let document = self.document.clone(); cx.spawn(async move |editor, cx| { let write_result = cx .background_executor() - .spawn(async move { flowstate_flow::save_flow_document(&path, &flowstate_flow::FlowDocument::new()).map_err(std::io::Error::other) }) + .spawn(async move { flowstate_flow::save_flow_document(&path, &document).map_err(std::io::Error::other) }) .await; - match write_result { - Ok(()) => { - let _ = editor.update(cx, |editor, cx| { - editor.dirty = false; - cx.notify(); - }); - Ok(()) - }, - Err(error) => Err(error), + if write_result.is_ok() { + let _ = editor.update(cx, |editor, cx| { + editor.dirty = false; + cx.notify(); + }); } + write_result }) } pub fn discard_recovery_file(&mut self) {} pub fn resolve_pending(&mut self, _cx: &mut Context) {} + + fn render_sheet(&self, sheet_id: SheetId, cx: &mut Context) -> AnyElement { + let sheet = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id); + let Some(sheet) = sheet else { + return div().child("Select a sheet").into_any_element(); + }; + let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { + return div().child("Invalid sheet type").into_any_element(); + }; + let active = self.active_cell; + let visible_rect = self.visible_board_rect(); + let inflation = 32.0 / self.board_zoom; + let strokes: Vec<_> = if !self.hidden_annotation_sheets.contains(&sheet_id) { + sheet + .annotations + .iter() + .filter(|stroke| { + !self.hidden_annotation_originators.contains(&stroke.originator) + && stroke.bbox.max.x >= visible_rect.min.x - inflation + && stroke.bbox.min.x <= visible_rect.max.x + inflation + && stroke.bbox.max.y >= visible_rect.min.y - inflation + && stroke.bbox.min.y <= visible_rect.max.y + inflation + }) + .cloned() + .collect() + } else { + Vec::new() + }; + let draft = self.drawing_points.clone(); + let annotation_active = self.annotation_tool != AnnotationTool::None && !self.space_pan_armed; + let connector_bounds = self.cell_bounds.clone(); + let connector_edges: Vec<_> = sheet + .cells + .iter() + .filter_map(|cell| { + let parent = cell.parent_id?; + let side = definition.columns.iter().find(|column| column.id == cell.column_id)?.side; + Some((parent, cell.id, side)) + }) + .collect(); + let weak_editor = cx.entity().downgrade(); + div() + .id("flow-columns") + .relative() + .flex() + .gap(px(12.0)) + .p(px(16.0)) + .children(definition.columns.iter().map(|column| { + let side_color = match column.side { + flowstate_flow::ArgumentSide::One => cx.theme().primary, + flowstate_flow::ArgumentSide::Two => cx.theme().info, + }; + div() + .w(px(280.0)) + .flex_none() + .flex_col() + .gap(px(8.0)) + .child( + div() + .font_weight(gpui::FontWeight::BOLD) + .text_color(side_color) + .border_b_2() + .border_color(side_color) + .child(column.label.clone()), + ) + .children(sheet.cells.iter().filter(|cell| cell.column_id == column.id).map(|cell| { + let id = cell.id; + let label: SharedString = cell + .summary_text() + .unwrap_or_else(|_| "Invalid rich text".into()) + .into(); + let estimated_lines = label + .lines() + .map(|line| line.chars().count().div_ceil(34).max(1)) + .sum::() + .max(1); + let cell_height = px(32.0 + 22.0 * estimated_lines as f32); + let cell_editor = self.cell_editors.get(&id).cloned(); + let is_drop_target = self.pending_cell_drop.is_some_and(|(target, _)| target == id); + div() + .id(("flow-cell", id.as_u128() as u64)) + .relative() + .h(cell_height) + .p(px(10.0)) + .rounded(px(6.0)) + .border_1() + .border_color(if active == Some(id) { + cx.theme().primary + } else { + side_color.opacity(0.56) + }) + .bg(cx.theme().background) + .cursor_pointer() + .on_click(cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { + let position = if event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0 { + RelativePosition::Before + } else { + RelativePosition::After + }; + editor.update_cell_drop(id, position, cx); + })) + .child( + div() + .absolute() + .inset_0() + .invisible() + .group_drag_over::("", |this| this.visible().border_2().border_color(cx.theme().primary)) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))), + ) + .when(is_drop_target, |this| this.border_2().border_color(cx.theme().primary)) + .child( + canvas( + { + let weak_editor = weak_editor.clone(); + move |bounds, _, cx| { + let _ = weak_editor.update(cx, |editor, cx| editor.set_cell_bounds(id, bounds, cx)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child( + div() + .id(("flow-cell-drag-handle", id.as_u128() as u64)) + .absolute() + .top(px(2.0)) + .right(px(4.0)) + .cursor_move() + .text_color(cx.theme().muted_foreground) + .child("⠿") + .on_mouse_down(MouseButton::Left, cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) + .on_drag(FlowCellDrag { cell_id: id }, |_, _, _, cx| cx.new(|_| FlowCellDragPreview)), + ) + .child(if active == Some(id) { + cell_editor.map_or_else(|| div().child(label).into_any_element(), |editor| editor.into_any_element()) + } else { + div().child(label).into_any_element() + }) + })) + })) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + for (parent_id, child_id, side) in &connector_edges { + let Some(parent) = connector_bounds.get(parent_id) else { + continue; + }; + let Some(child) = connector_bounds.get(child_id) else { + continue; + }; + let color = match side { + flowstate_flow::ArgumentSide::One => cx.theme().primary, + flowstate_flow::ArgumentSide::Two => cx.theme().info, + }; + let start = point(parent.right() - bounds.origin.x, parent.center().y - bounds.origin.y); + let end = point(child.left() - bounds.origin.x, child.center().y - bounds.origin.y); + let midpoint = start.x + (end.x - start.x) / 2.0; + let mut path = PathBuilder::stroke(px(1.5)); + path.move_to(start); + path.line_to(point(midpoint, start.y)); + path.line_to(point(midpoint, end.y)); + path.line_to(end); + if let Ok(path) = path.build() { + window.paint_path(path, color.opacity(0.68)); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .id("flow-annotation-layer") + .absolute() + .inset_0() + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for stroke in &strokes { + paint_stroke( + bounds.origin, + &stroke.points, + px(stroke.style.width), + gpui::Hsla::from(rgba(stroke.style.color_rgba)).opacity(stroke.style.opacity), + window, + ); + } + if !draft.is_empty() { + paint_stroke(bounds.origin, &draft, px(4.0), gpui::Hsla::from(rgba(0xfff5_9e0b)).opacity(0.55), window); + } + }, + ) + .absolute() + .size_full(), + ) + .when(annotation_active, |this| { + this + .on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| { + editor.begin_annotation(event.position, cx); + cx.stop_propagation(); + })) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, cx| editor.continue_annotation(event.position, cx))) + .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx))) + }), + ) + .into_any_element() + } +} + +fn stroke_bbox(points: &[BoardPoint]) -> BoardRect { + let mut min = points[0]; + let mut max = points[0]; + for point in &points[1..] { + min.x = min.x.min(point.x); + min.y = min.y.min(point.y); + max.x = max.x.max(point.x); + max.y = max.y.max(point.y); + } + BoardRect { min, max } +} + +fn simplify_stroke(points: &[BoardPoint], minimum_distance: f32) -> Vec { + let Some(first) = points.first().copied() else { + return Vec::new(); + }; + let mut simplified = Vec::with_capacity(points.len()); + simplified.push(first); + for window in points.windows(3) { + let smoothed = BoardPoint { + x: (window[0].x + window[1].x + window[2].x) / 3.0, + y: (window[0].y + window[1].y + window[2].y) / 3.0, + }; + if simplified + .last() + .is_none_or(|previous| (smoothed.x - previous.x).hypot(smoothed.y - previous.y) >= minimum_distance) + { + simplified.push(smoothed); + } + } + if let Some(last) = points.last().copied() + && simplified.last() != Some(&last) + { + simplified.push(last); + } + simplified +} + +fn segment_distance(point: BoardPoint, start: BoardPoint, end: BoardPoint) -> f32 { + let dx = end.x - start.x; + let dy = end.y - start.y; + let length_squared = dx * dx + dy * dy; + let t = if length_squared == 0.0 { + 0.0 + } else { + (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0) + }; + let nearest_x = start.x + t * dx; + let nearest_y = start.y + t * dy; + (point.x - nearest_x).hypot(point.y - nearest_y) +} + +fn paint_stroke(origin: gpui::Point, points: &[BoardPoint], width: gpui::Pixels, color: gpui::Hsla, window: &mut Window) { + let Some(first) = points.first() else { + return; + }; + let mut path = PathBuilder::stroke(width); + path.move_to(point(origin.x + px(first.x), origin.y + px(first.y))); + for point_value in &points[1..] { + path.line_to(point(origin.x + px(point_value.x), origin.y + px(point_value.y))); + } + if let Ok(path) = path.build() { + window.paint_path(path, color); + } } -impl EventEmitter<()> for FlowEditor {} +impl EventEmitter for FlowEditor {} impl Focusable for FlowEditor { fn focus_handle(&self, _: &App) -> FocusHandle { @@ -85,7 +1016,65 @@ impl Focusable for FlowEditor { } impl Render for FlowEditor { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().size_full() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("flow-editor") + .relative() + .size_full() + .overflow_scroll() + .track_scroll(&self.board_scroll) + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|editor, event: &KeyDownEvent, _, cx| { + if event.keystroke.key == "space" { + editor.space_pan_armed = true; + cx.stop_propagation(); + cx.notify(); + } + })) + .on_key_up(cx.listener(|editor, event: &KeyUpEvent, _, cx| { + if event.keystroke.key == "space" { + editor.space_pan_armed = false; + editor.finish_space_pan(cx); + cx.stop_propagation(); + } + })) + .on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| { + if editor.space_pan_armed { + editor.begin_space_pan(event.position, cx); + cx.stop_propagation(); + } + })) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, _| editor.continue_space_pan(event.position))) + .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_space_pan(cx))) + .child( + canvas( + { + let weak = cx.entity().downgrade(); + move |bounds, _, cx| { + let _ = weak.update(cx, |editor, _| editor.set_viewport_origin(bounds.origin)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(match self.active_sheet { + Some(sheet) => self.render_sheet(sheet, cx).into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child("Create a sheet to begin flowing") + .into_any_element(), + }) + .child( + div() + .absolute() + .inset_0() + .child(Scrollbar::horizontal(&self.board_scroll).scrollbar_show(ScrollbarShow::Scrolling)), + ) } } diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index e983a964..0a85e28c 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -2,6 +2,6 @@ pub mod editor; pub mod panel; pub mod ribbon; -pub use editor::FlowEditor; +pub use editor::{AnnotationTool, FlowEditor}; pub use panel::FlowPanel; pub use ribbon::FlowRibbon; diff --git a/crates/flowstate/src/flow/ribbon.rs b/crates/flowstate/src/flow/ribbon.rs index 34e008da..4d9b688c 100644 --- a/crates/flowstate/src/flow/ribbon.rs +++ b/crates/flowstate/src/flow/ribbon.rs @@ -1,6 +1,10 @@ -use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Window, div, prelude::*, px}; +use flowstate_flow::RelativePosition; +use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Subscription, Window, div, prelude::*, px}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme as _, Disableable as _, Sizable as _}; -use crate::flow::FlowEditor; +use crate::flow::{AnnotationTool, FlowEditor}; pub struct FlowRibbon { editor: Entity, @@ -8,6 +12,11 @@ pub struct FlowRibbon { height: gpui::Pixels, } +struct SheetNameInputState { + input: Entity, + _subscription: Subscription, +} + impl FlowRibbon { pub fn new(editor: Entity, _window: &mut Window, cx: &mut Context) -> Self { Self { @@ -38,7 +47,183 @@ impl Focusable for FlowRibbon { } impl Render for FlowRibbon { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().w_full().h(self.height) + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let editor = self.editor.clone(); + let undo_editor = editor.clone(); + let redo_editor = editor.clone(); + let create_aff_editor = editor.clone(); + let create_neg_editor = editor.clone(); + let response_editor = editor.clone(); + let first_argument_editor = editor.clone(); + let previous_sheet_editor = editor.clone(); + let next_sheet_editor = editor.clone(); + let delete_sheet_editor = editor.clone(); + let above_editor = editor.clone(); + let below_editor = editor.clone(); + let delete_editor = editor.clone(); + let strike_editor = editor; + let marker_editor = self.editor.clone(); + let eraser_editor = self.editor.clone(); + let visibility_editor = self.editor.clone(); + let clear_editor = self.editor.clone(); + let has_active_sheet = self.editor.read(cx).active_sheet().is_some(); + let has_active_cell = self.editor.read(cx).active_cell().is_some(); + let sheet_name_input = self.editor.read(cx).active_sheet().and_then(|sheet_id| { + let sheet_name = self + .editor + .read(cx) + .document() + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id)? + .name + .clone(); + let editor = self.editor.clone(); + let state = window.use_keyed_state(("flow-sheet-name-input", sheet_id.as_u128() as u64), cx, move |window, cx| { + let input = cx.new(|cx| InputState::new(window, cx).default_value(sheet_name).placeholder("Sheet name")); + let subscription = cx.subscribe_in(&input, window, move |_: &mut SheetNameInputState, input, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Change) { + let name = input.read(cx).value().to_string(); + editor.update(cx, |editor, cx| editor.rename_active_sheet(name, cx)); + } + }); + SheetNameInputState { + input, + _subscription: subscription, + } + }); + Some(state.read(cx).input.clone()) + }); + div() + .w_full() + .h(self.height) + .flex() + .items_center() + .gap(px(8.0)) + .px(px(12.0)) + .bg(cx.theme().secondary) + .when_some(sheet_name_input, |this, input| this.child(div().w(px(180.0)).child(Input::new(&input).w_full()))) + .child( + Button::new("flow-undo") + .label("Undo") + .small() + .disabled(!self.editor.read(cx).can_undo()) + .on_click(move |_, _, cx| undo_editor.update(cx, |editor, cx| editor.undo(cx))), + ) + .child( + Button::new("flow-redo") + .label("Redo") + .small() + .disabled(!self.editor.read(cx).can_redo()) + .on_click(move |_, _, cx| redo_editor.update(cx, |editor, cx| editor.redo(cx))), + ) + .child( + Button::new("flow-create-aff-sheet") + .label("New affirmative") + .small() + .on_click(move |_, _, cx| create_aff_editor.update(cx, |editor, cx| editor.create_sheet_of_type(0, cx))), + ) + .child( + Button::new("flow-create-neg-sheet") + .label("New negative") + .small() + .on_click(move |_, _, cx| create_neg_editor.update(cx, |editor, cx| editor.create_sheet_of_type(1, cx))), + ) + .child( + Button::new("flow-move-sheet-left") + .label("Move sheet left") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| previous_sheet_editor.update(cx, |editor, cx| editor.move_active_sheet(-1, cx))), + ) + .child( + Button::new("flow-move-sheet-right") + .label("Move sheet right") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| next_sheet_editor.update(cx, |editor, cx| editor.move_active_sheet(1, cx))), + ) + .child( + Button::new("flow-delete-sheet") + .label("Delete sheet") + .small() + .danger() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| delete_sheet_editor.update(cx, |editor, cx| editor.delete_active_sheet(cx))), + ) + .child( + Button::new("flow-add-first-argument") + .label("Add argument") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| first_argument_editor.update(cx, |editor, cx| editor.add_first_argument(cx))), + ) + .child( + Button::new("flow-add-response") + .label("Add response") + .small() + .disabled(!has_active_cell) + .on_click(move |_, _, cx| response_editor.update(cx, |editor, cx| editor.add_response(cx))), + ) + .child( + Button::new("flow-add-sibling-above") + .label("Sibling above") + .small() + .disabled(!has_active_cell) + .on_click(move |_, _, cx| above_editor.update(cx, |editor, cx| editor.add_sibling(RelativePosition::Before, cx))), + ) + .child( + Button::new("flow-add-sibling") + .label("Add sibling") + .small() + .disabled(!has_active_cell) + .on_click(move |_, _, cx| below_editor.update(cx, |editor, cx| editor.add_sibling(RelativePosition::After, cx))), + ) + .child( + Button::new("flow-delete-selected") + .label("Delete selected") + .small() + .danger() + .disabled(!has_active_cell) + .on_click(move |_, window, cx| delete_editor.update(cx, |editor, cx| editor.delete_selected(window, cx))), + ) + .child( + Button::new("flow-strike-selected") + .label("Strike") + .small() + .disabled(!has_active_cell) + .on_click(move |_, _, cx| strike_editor.update(cx, |editor, cx| editor.strike_selected(cx))), + ) + .child( + Button::new("flow-arm-marker") + .label("Marker") + .small() + .on_click(move |_, _, cx| marker_editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::Marker, cx))), + ) + .child( + Button::new("flow-arm-eraser") + .label("Eraser") + .small() + .on_click(move |_, _, cx| eraser_editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::Eraser, cx))), + ) + .child( + Button::new("flow-toggle-annotations") + .label(if self.editor.read(cx).annotations_visible() { + "Hide annotations" + } else { + "Show annotations" + }) + .small() + .on_click(move |_, _, cx| visibility_editor.update(cx, |editor, cx| editor.toggle_annotations_visible(cx))), + ) + .child( + Button::new("flow-clear-annotations") + .label("Clear annotations") + .small() + .danger() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| clear_editor.update(cx, |editor, cx| editor.clear_annotations(cx))), + ) } } diff --git a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs index 7983dcd4..ac89367f 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs @@ -194,6 +194,7 @@ fn modern_condensed_menu( command: &RibbonCommand, editor: Entity, metrics: RibbonLayoutMetrics, + options: ModernRibbonOptions, cx: &mut Context, ) -> AnyElement { let mode_active = command.selected; @@ -201,6 +202,7 @@ fn modern_condensed_menu( let chip_height = metrics.chip_height; let label = RibbonLabel::for_command(command); let command_color = ribbon_command_color(command, cx); + let shortcut = command.shortcut.clone(); DropdownButton::new("modern-ribbon-condensed-dropdown") .with_size(Size::Size(chip_height)) @@ -239,6 +241,9 @@ fn modern_condensed_menu( .child(label.text), ) }) + .when(show_shortcut(options), |this| { + this.when_some(shortcut, |this, shortcut| this.child(keycap(shortcut, cx))) + }) .on_click({ let editor = editor.clone(); move |_, window, cx| { diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs index 7443cb77..f600f11f 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs @@ -41,7 +41,7 @@ fn modern_group( } else if matches!(command.id, RibbonCommandId::CondenseMenu) { modern_condense_menu(command, editor.clone(), metrics, options, cx) } else if matches!(command.id, RibbonCommandId::CondensedMenu) { - modern_condensed_menu(command, editor.clone(), metrics, cx) + modern_condensed_menu(command, editor.clone(), metrics, options, cx) } else if matches!(command.id, RibbonCommandId::Undo) { modern_undo_button(command, editor.clone(), metrics, cx) } else if matches!(command.id, RibbonCommandId::Redo) { diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs index a229d6bd..b39e9c56 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs @@ -100,7 +100,7 @@ impl RibbonLabel { RibbonCommandId::ToggleSpeechDocument => Some("icons/speech.svg"), RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => Some("icons/send.svg"), RibbonCommandId::MarkCard => Some("icons/mark-card.svg"), - RibbonCommandId::ClearFormatting => Some("icons/eraser.svg"), + RibbonCommandId::ClearFormatting => Some("icons/remove-formatting.svg"), RibbonCommandId::ExportFormat => Some("icons/export.svg"), RibbonCommandId::ExportSend => Some("icons/send-horizontal.svg"), _ => None, diff --git a/crates/flowstate/src/rich_text_element/mod.rs b/crates/flowstate/src/rich_text_element/mod.rs index 16f42a9e..b5aa2d38 100644 --- a/crates/flowstate/src/rich_text_element/mod.rs +++ b/crates/flowstate/src/rich_text_element/mod.rs @@ -88,6 +88,9 @@ pub fn flowstate_command_to_rich_text(command: CommandId) -> Option return None, + | CommandId::ScrollToParagraph + | CommandId::FlowAddSiblingAbove + | CommandId::FlowDeleteSelected + | CommandId::FlowStrike => return None, }) } diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 3baae92b..02daac0e 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -465,17 +465,13 @@ impl Workspace { cx.notify(); } - fn insert_toolkit_hit(&mut self, hit_ix: usize, window: &mut Window, cx: &mut Context) { + fn insert_toolkit_hit(&mut self, hit_ix: usize, _window: &mut Window, cx: &mut Context) { let Some(hit) = self.toolkit_hits.get(hit_ix).cloned() else { return; }; if let Some(editor) = self.active_editor.clone() { let paragraphs = toolkit_hit_insert_paragraphs(&hit); editor.update(cx, |editor, cx| editor.insert_toolkit_text_at_caret(paragraphs, cx)); - return; - } - if self.active_flow.is_some() { - // Flow toolkit text insertion is not yet supported. } } @@ -884,6 +880,9 @@ impl Workspace { is_active, has_search_match: false, guide, + hierarchy_color: None, + guide_colors: None, + incoming_branch: None, icon: Some(icon), icon_color: Some(icon_color), toggle_action: Some(toggle_action), diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 3235215c..eb6d9758 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -449,10 +449,12 @@ impl Workspace { continue; } let loaded = if matches!(entry.kind, TemporaryWorkspaceSessionEntryKind::Flow) && is_flow_path(&entry.path) { - Some(LoadedWorkspaceDocument::Flow { - document: flowstate_flow::load_flow_document_or_new(&entry.path), - path: entry.path, - }) + flowstate_flow::load_flow_document(&entry.path) + .ok() + .map(|document| LoadedWorkspaceDocument::Flow { + document, + path: entry.path, + }) } else { load_workspace_document(entry.path).ok() }; @@ -1164,10 +1166,8 @@ enum LoadedWorkspaceDocument { #[hotpath::measure] fn load_workspace_document(path: PathBuf) -> Result { if is_flow_path(&path) { - return Ok(LoadedWorkspaceDocument::Flow { - document: flowstate_flow::load_flow_document_or_new(&path), - path, - }); + let document = flowstate_flow::load_flow_document(&path).map_err(|error| error.to_string())?; + return Ok(LoadedWorkspaceDocument::Flow { document, path }); } load_document_for_open(&path) .map(|loaded| LoadedWorkspaceDocument::Document { diff --git a/crates/flowstate/src/workspace/workspace/keybindings.rs b/crates/flowstate/src/workspace/workspace/keybindings.rs index 83fd8507..5485875b 100644 --- a/crates/flowstate/src/workspace/workspace/keybindings.rs +++ b/crates/flowstate/src/workspace/workspace/keybindings.rs @@ -35,6 +35,46 @@ impl Workspace { if self.non_document_keybinding_surface_is_open() || self.focused_workspace_input_is_focused(window, cx) { return false; } + if let Some(flow) = self.active_flow.clone() { + let handled = match command { + CommandId::Undo => { + flow.update(cx, |editor, cx| editor.undo(cx)); + true + }, + CommandId::Redo => { + flow.update(cx, |editor, cx| editor.redo(cx)); + true + }, + CommandId::InsertNewline => { + flow.update(cx, |editor, cx| editor.add_sibling(flowstate_flow::RelativePosition::After, cx)); + true + }, + CommandId::InsertSoftLineBreak => { + flow.update(cx, |editor, cx| editor.add_response(cx)); + true + }, + CommandId::FlowAddSiblingAbove => { + flow.update(cx, |editor, cx| editor.add_sibling(flowstate_flow::RelativePosition::Before, cx)); + true + }, + CommandId::Backspace if flow.read(cx).active_cell_is_empty() => { + flow.update(cx, |editor, cx| editor.delete_selected(window, cx)); + true + }, + CommandId::DeleteWordForward | CommandId::FlowDeleteSelected => { + flow.update(cx, |editor, cx| editor.delete_selected(window, cx)); + true + }, + CommandId::ToggleStrikethrough | CommandId::FlowStrike => { + flow.update(cx, |editor, cx| editor.strike_selected(cx)); + true + }, + _ => false, + }; + if handled { + return true; + } + } match command { CommandId::Save => { self.save_active(window, cx); @@ -165,6 +205,7 @@ impl Workspace { true }, CommandId::ScrollToParagraph => false, + CommandId::FlowAddSiblingAbove | CommandId::FlowDeleteSelected | CommandId::FlowStrike => false, command => { if let Some(editor) = self.active_editor.clone() { if let Some(command) = crate::rich_text_element::flowstate_command_to_rich_text(command) { diff --git a/crates/flowstate/src/workspace/workspace/render_body.rs b/crates/flowstate/src/workspace/workspace/render_body.rs index f6bb2b27..15a0d787 100644 --- a/crates/flowstate/src/workspace/workspace/render_body.rs +++ b/crates/flowstate/src/workspace/workspace/render_body.rs @@ -1,6 +1,6 @@ #[hotpath::measure_all] impl Workspace { - fn render_resizable_workspace(&mut self, cx: &mut Context) -> impl IntoElement { + fn render_resizable_workspace(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if self.document_panels.is_empty() && self.flow_panels.is_empty() { return div() .flex_1() @@ -14,7 +14,7 @@ impl Workspace { .flex_1() .overflow_hidden() .child(self.render_collapsed_ribbon_bar(cx)) - .child(self.render_workspace_body(cx)) + .child(self.render_workspace_body(window, cx)) .into_any_element(); } @@ -43,12 +43,12 @@ impl Workspace { resizable_panel() .size(px(640.0)) .size_range(px(320.0)..Pixels::MAX) - .child(self.render_workspace_body(cx)), + .child(self.render_workspace_body(window, cx)), ) .into_any_element() } - fn render_workspace_body(&mut self, cx: &mut Context) -> impl IntoElement { + fn render_workspace_body(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let panel_sizes = self.body_resizable_state.read(cx).sizes().clone(); let outline_width = if self.outline_collapsed { SIDE_PANEL_COLLAPSED_WIDTH @@ -74,7 +74,7 @@ impl Workspace { .render_collapsed_side_panel("Show outline", IconName::PanelLeftOpen, |workspace, cx| workspace.toggle_outline(cx), cx) .into_any_element() } else { - self.render_left_nav(nav_width, cx).into_any_element() + self.render_left_nav(nav_width, window, cx).into_any_element() }), ) .child( diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index b9f69b4e..98f203a9 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -1,11 +1,11 @@ #[hotpath::measure_all] impl Workspace { - fn render_left_nav(&mut self, nav_width: Pixels, cx: &mut Context) -> AnyElement { + fn render_left_nav(&mut self, nav_width: Pixels, window: &mut Window, cx: &mut Context) -> AnyElement { if self.left_nav_mode == LeftNavMode::Tub { return self.render_tub_nav(nav_width, cx); } if self.active_flow.is_some() { - return self.render_flow_nav(cx); + return self.render_flow_nav(nav_width, window, cx); } self.refresh_outline_tree(cx); self.refresh_outline_viewport(cx); @@ -94,6 +94,9 @@ impl Workspace { is_active: is_active_outline, has_search_match, guide, + hierarchy_color: None, + guide_colors: None, + incoming_branch: None, icon: None, icon_color: None, toggle_action: Some(toggle_action), @@ -135,27 +138,203 @@ impl Workspace { outline_paragraphs } - fn render_flow_nav(&mut self, cx: &mut Context) -> AnyElement { + fn render_flow_nav(&mut self, nav_width: Pixels, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(editor) = self.active_flow.clone() else { + return div().into_any_element(); + }; + let projection = editor.read(cx).document().projection().clone(); + let active_sheet = editor.read(cx).active_sheet(); + let active_cell = editor.read(cx).active_cell(); + let mut rows = Vec::new(); + for sheet in &projection.sheets { + let sheet_id = sheet.id; + let sheet_expanded = editor.read(cx).outline_item_expanded(sheet_id); + let toggle_editor = editor.clone(); + let activate_editor = editor.clone(); + let key = sheet_id.as_u128() as usize; + rows.push( + render_sidebar_tree_row( + SidebarTreeRow { + row_id: ("flow-outline-sheet", key), + toggle_id: ("flow-outline-sheet-toggle", key), + label_id: ("flow-outline-sheet-label", key), + label: sheet.name.clone().into(), + nav_width, + depth: 0, + is_folder: !sheet.cells.is_empty(), + is_expanded: sheet_expanded, + is_active: active_sheet == Some(sheet_id) && active_cell.is_none(), + has_search_match: false, + guide: OutlineRowGuides::default(), + hierarchy_color: None, + guide_colors: None, + incoming_branch: None, + icon: None, + icon_color: None, + toggle_action: Some(Rc::new(move |_, cx| { + toggle_editor.update(cx, |editor, cx| editor.toggle_outline_item(sheet_id, cx)); + })), + label_action: Rc::new(move |_, cx| activate_editor.update(cx, |editor, cx| editor.activate_sheet(sheet_id, cx))), + stop_icon_mouse_down: true, + stop_label_mouse_down: true, + context_menu_action: None, + }, + window, + cx, + ) + .into_any_element(), + ); + if !sheet_expanded { + continue; + } + let Some(definition) = projection.format.sheet_type(sheet.sheet_type_id) else { + continue; + }; + let roots: Vec<_> = sheet.cells.iter().filter(|cell| cell.parent_id.is_none()).map(|cell| cell.id).collect(); + for cell_id in roots { + append_flow_outline_cell_rows( + sheet, + definition, + cell_id, + &editor, + active_cell, + nav_width, + window, + &[0], + true, + cx, + &mut rows, + ); + } + } v_flex() .size_full() .h_full() .p_2() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) - .child( - div() - .w_full() - .h_full() - .flex() - .items_center() - .justify_center() - .text_sm() - .child("Flow"), - ) + .child(self.render_left_nav_header("Flow", cx)) + .child(div().flex_1().w_full().overflow_y_scrollbar().children(rows)) .into_any_element() } } +fn append_flow_outline_cell_rows( + sheet: &flowstate_flow::Sheet, + definition: &flowstate_flow::SheetTypeDefinition, + cell_id: flowstate_flow::CellId, + editor: &Entity, + active_cell: Option, + nav_width: Pixels, + window: &mut Window, + ancestor_depths: &[usize], + is_final_child: bool, + cx: &mut Context, + rows: &mut Vec, +) { + let Some(cell) = sheet.cells.iter().find(|cell| cell.id == cell_id) else { + return; + }; + let Some(column_depth) = definition.columns.iter().position(|column| column.id == cell.column_id) else { + return; + }; + let depth = column_depth + 1; + let children: Vec<_> = sheet + .cells + .iter() + .filter(|candidate| candidate.parent_id == Some(cell_id)) + .map(|candidate| candidate.id) + .collect(); + let expanded = editor.read(cx).outline_item_expanded(cell_id); + let side_color = match definition.columns[column_depth].side { + flowstate_flow::ArgumentSide::One => cx.theme().primary, + flowstate_flow::ArgumentSide::Two => cx.theme().info, + }; + let guide_colors = (0..depth) + .map(|guide_depth| { + if guide_depth == 0 { + outline_hierarchy_color(0, cx) + } else { + match definition.columns[guide_depth - 1].side { + flowstate_flow::ArgumentSide::One => cx.theme().primary, + flowstate_flow::ArgumentSide::Two => cx.theme().info, + } + } + }) + .collect(); + let label = cell + .summary_text() + .map(|text| if text.trim().is_empty() { "(empty)".into() } else { text }) + .unwrap_or_else(|_| "(invalid rich text)".into()); + let activate_editor = editor.clone(); + let toggle_editor = editor.clone(); + let incoming_branch = cell.parent_id.map(|_| IncomingBranch { + parent_depth: depth.saturating_sub(1), + is_final_child, + }); + let guide = OutlineRowGuides { + ancestor_depths: ancestor_depths.to_vec(), + // Flow branches are rendered in their parent's indentation slot. The + // document-outline toggle extension uses a different coordinate and would + // leave a short duplicate segment below every expanded flow parent. + extends_from_toggle: false, + }; + let key = cell_id.as_u128() as usize; + rows.push( + render_sidebar_tree_row( + SidebarTreeRow { + row_id: ("flow-outline-cell", key), + toggle_id: ("flow-outline-toggle", key), + label_id: ("flow-outline-label", key), + label: label.into(), + nav_width, + depth, + is_folder: !children.is_empty(), + is_expanded: expanded, + is_active: active_cell == Some(cell_id), + has_search_match: false, + guide, + hierarchy_color: Some(side_color), + guide_colors: Some(guide_colors), + incoming_branch, + icon: None, + icon_color: None, + toggle_action: Some(Rc::new(move |_, cx| { + toggle_editor.update(cx, |editor, cx| editor.toggle_outline_item(cell_id, cx)); + })), + label_action: Rc::new(move |_, cx| activate_editor.update(cx, |editor, cx| editor.activate_cell(cell_id, cx))), + stop_icon_mouse_down: true, + stop_label_mouse_down: true, + context_menu_action: None, + }, + window, + cx, + ) + .into_any_element(), + ); + if expanded { + let mut child_ancestors = ancestor_depths.to_vec(); + if !is_final_child { + child_ancestors.push(depth.saturating_sub(1)); + } + for (index, child_id) in children.iter().copied().enumerate() { + append_flow_outline_cell_rows( + sheet, + definition, + child_id, + editor, + active_cell, + nav_width, + window, + &child_ancestors, + index + 1 == children.len(), + cx, + rows, + ); + } + } +} + type SidebarTreeAction = Rc; type ContextMenuAction = Rc, &mut Window, &mut App)>; @@ -171,6 +350,9 @@ struct SidebarTreeRow { is_active: bool, has_search_match: bool, guide: OutlineRowGuides, + hierarchy_color: Option, + guide_colors: Option>, + incoming_branch: Option, icon: Option, icon_color: Option, toggle_action: Option, @@ -180,9 +362,17 @@ struct SidebarTreeRow { context_menu_action: Option, } +#[derive(Clone, Copy)] +struct IncomingBranch { + parent_depth: usize, + is_final_child: bool, +} + fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut App) -> ListItem { - let hierarchy_color = outline_hierarchy_color(row.depth, cx); + let hierarchy_color = row.hierarchy_color.unwrap_or_else(|| outline_hierarchy_color(row.depth, cx)); + let guide_colors = row.guide_colors; let guide_depths = row.guide.ancestor_depths; + let incoming_branch = row.incoming_branch; let label_width = outline_label_width(row.nav_width, row.depth); let label = truncate_outline_label(row.label.as_ref(), outline_label_text_width(label_width, window), window, cx); let icon_color = row.icon_color.unwrap_or(hierarchy_color); @@ -219,13 +409,18 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .gap_1() .children((0..row.depth).map(|guide_depth| { let has_guide = guide_depths.contains(&guide_depth); - let guide_color = outline_hierarchy_color(guide_depth, cx); + let incoming_branch = incoming_branch.filter(|branch| branch.parent_depth == guide_depth); + let guide_color = guide_colors + .as_ref() + .and_then(|colors| colors.get(guide_depth)) + .copied() + .unwrap_or_else(|| outline_hierarchy_color(guide_depth, cx)); div() .relative() .w(px(12.0)) .h(px(20.0)) .flex_none() - .when(has_guide, |this| { + .when(has_guide && incoming_branch.is_none(), |this| { this.child( div() .absolute() @@ -236,6 +431,42 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .bg(guide_color.opacity(0.68)), ) }) + .when_some(incoming_branch, |this, branch| { + if branch.is_final_child { + this.child( + div() + .absolute() + .top_0() + .left(px(11.0)) + .w(px(9.0)) + .h(px(10.5)) + .border_l_1() + .border_b_1() + .border_color(guide_color.opacity(0.68)) + .rounded_bl(cx.theme().radius.min(px(10.0))), + ) + } else { + this + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(11.5)) + .w(px(0.5)) + .bg(guide_color.opacity(0.68)), + ) + .child( + div() + .absolute() + .top(px(10.0)) + .left(px(11.5)) + .w(px(8.5)) + .h(px(0.5)) + .bg(guide_color.opacity(0.68)), + ) + } + }) .into_any_element() })) .child( @@ -264,7 +495,10 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap this.child( Button::new(row.toggle_id) .xsmall() - .ghost() + .when(incoming_branch.is_none(), |button| button.ghost()) + .when(incoming_branch.is_some(), |button| { + button.custom(ButtonCustomVariant::new(cx).foreground(hierarchy_color)) + }) .absolute() .top_0() .left_0() diff --git a/crates/flowstate/src/workspace/workspace/traits.rs b/crates/flowstate/src/workspace/workspace/traits.rs index 4111d93b..4bea6a21 100644 --- a/crates/flowstate/src/workspace/workspace/traits.rs +++ b/crates/flowstate/src/workspace/workspace/traits.rs @@ -37,7 +37,7 @@ impl Render for Workspace { .flex_1() .min_h_0() .overflow_hidden() - .child(self.render_resizable_workspace(cx)), + .child(self.render_resizable_workspace(window, cx)), ) .child(self.render_status_bar(window, cx)), ) diff --git a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs index 267e82ec..f68b1c4e 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs @@ -178,6 +178,9 @@ impl RichTextEditor { self.delete_forward_command(cx); } fn on_insert_newline(&mut self, _: &InsertNewline, _: &mut Window, cx: &mut Context) { + if !self.config.allow_paragraph_breaks { + return; + } if self.split_selected_table_cell_paragraph(cx) { return; } diff --git a/crates/gpui-flowtext/src/rich_text/editor/commands.rs b/crates/gpui-flowtext/src/rich_text/editor/commands.rs index 2e3a3026..bbaac85d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/commands.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/commands.rs @@ -483,6 +483,19 @@ impl RichTextEditor { let Some(item) = cx.read_from_clipboard() else { return; }; + if !self.config.allow_paragraph_breaks { + let rich_single_paragraph = item + .metadata() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .filter(|fragment| fragment.blocks.is_empty() && fragment.paragraphs.len() <= 1); + if let Some(fragment) = rich_single_paragraph { + self.apply_document_edit(cx, |editor, cx| editor.insert_rich_fragment(fragment, cx)); + } else if let Some(text) = item.text() { + let flattened = text.split_whitespace().collect::>().join(" "); + self.insert_text_command(&flattened, cx); + } + return; + } if matches!(self.selected_block, Some(BlockSelection::Equation(_))) { if let Some(text) = item.text() { self.insert_text_into_selected_equation(&text, cx); diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 646f429d..d670eecf 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -448,12 +448,16 @@ pub struct RichTextEditorStyleState { #[derive(Clone, Debug, Eq, PartialEq)] pub struct RichTextEditorConfig { pub smart_word_selection: bool, + pub allow_paragraph_breaks: bool, } #[hotpath::measure_all] impl Default for RichTextEditorConfig { fn default() -> Self { - Self { smart_word_selection: true } + Self { + smart_word_selection: true, + allow_paragraph_breaks: true, + } } } From b9c57d75843ad0efe55cc928ce5bc2d5701bcd4f Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sat, 13 Jun 2026 15:39:17 -0500 Subject: [PATCH 05/24] UI fixes --- crates/flowstate/src/flow/editor.rs | 26 +++--- crates/flowstate/src/flow/mod.rs | 28 ++++++ .../flowstate/src/workspace/toolkit_panel.rs | 2 + .../src/workspace/workspace/render_outline.rs | 90 +++++++++++++++---- 4 files changed, 115 insertions(+), 31 deletions(-) diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 6812ca49..b204879f 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -13,7 +13,7 @@ use gpui_component::ActiveTheme as _; use gpui_component::PixelsExt as _; use gpui_component::scroll::{Scrollbar, ScrollbarShow}; -use crate::rich_text_element::RichTextEditor; +use crate::{flow::flow_side_palette, rich_text_element::RichTextEditor}; #[derive(Clone, Debug)] pub enum FlowEditorEvent { @@ -774,10 +774,8 @@ impl FlowEditor { .gap(px(12.0)) .p(px(16.0)) .children(definition.columns.iter().map(|column| { - let side_color = match column.side { - flowstate_flow::ArgumentSide::One => cx.theme().primary, - flowstate_flow::ArgumentSide::Two => cx.theme().info, - }; + let side_palette = flow_side_palette(column.side, cx); + let side_color = side_palette.base; div() .w(px(280.0)) .flex_none() @@ -813,11 +811,16 @@ impl FlowEditor { .rounded(px(6.0)) .border_1() .border_color(if active == Some(id) { - cx.theme().primary + side_palette.active } else { side_color.opacity(0.56) }) - .bg(cx.theme().background) + .bg(if active == Some(id) { + side_palette.active.opacity(0.14) + } else { + cx.theme().background + }) + .hover(|style| style.bg(side_palette.hover.opacity(0.12))) .cursor_pointer() .on_click(cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { @@ -833,10 +836,10 @@ impl FlowEditor { .absolute() .inset_0() .invisible() - .group_drag_over::("", |this| this.visible().border_2().border_color(cx.theme().primary)) + .group_drag_over::("", |this| this.visible().border_2().border_color(side_palette.hover)) .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))), ) - .when(is_drop_target, |this| this.border_2().border_color(cx.theme().primary)) + .when(is_drop_target, |this| this.border_2().border_color(side_palette.active)) .child( canvas( { @@ -880,10 +883,7 @@ impl FlowEditor { let Some(child) = connector_bounds.get(child_id) else { continue; }; - let color = match side { - flowstate_flow::ArgumentSide::One => cx.theme().primary, - flowstate_flow::ArgumentSide::Two => cx.theme().info, - }; + let color = flow_side_palette(*side, cx).base; let start = point(parent.right() - bounds.origin.x, parent.center().y - bounds.origin.y); let end = point(child.left() - bounds.origin.x, child.center().y - bounds.origin.y); let midpoint = start.x + (end.x - start.x) / 2.0; diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index 0a85e28c..57d64d4f 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -5,3 +5,31 @@ pub mod ribbon; pub use editor::{AnnotationTool, FlowEditor}; pub use panel::FlowPanel; pub use ribbon::FlowRibbon; + +use gpui::{App, Hsla}; +use gpui_component::ActiveTheme as _; + +#[derive(Clone, Copy)] +pub(crate) struct FlowSidePalette { + pub base: Hsla, + pub foreground: Hsla, + pub hover: Hsla, + pub active: Hsla, +} + +pub(crate) fn flow_side_palette(side: flowstate_flow::ArgumentSide, cx: &App) -> FlowSidePalette { + match side { + flowstate_flow::ArgumentSide::One => FlowSidePalette { + base: cx.theme().primary, + foreground: cx.theme().primary_foreground, + hover: cx.theme().primary_hover, + active: cx.theme().primary_active, + }, + flowstate_flow::ArgumentSide::Two => FlowSidePalette { + base: cx.theme().info, + foreground: cx.theme().info_foreground, + hover: cx.theme().info_hover, + active: cx.theme().info_active, + }, + } +} diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 02daac0e..151c9551 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -881,6 +881,8 @@ impl Workspace { has_search_match: false, guide, hierarchy_color: None, + label_color: None, + interaction_palette: None, guide_colors: None, incoming_branch: None, icon: Some(icon), diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index 98f203a9..0fe0b862 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -95,6 +95,8 @@ impl Workspace { has_search_match, guide, hierarchy_color: None, + label_color: None, + interaction_palette: None, guide_colors: None, incoming_branch: None, icon: None, @@ -149,6 +151,16 @@ impl Workspace { for sheet in &projection.sheets { let sheet_id = sheet.id; let sheet_expanded = editor.read(cx).outline_item_expanded(sheet_id); + let sheet_palette = projection.format.sheet_type(sheet.sheet_type_id).and_then(|definition| definition.columns.first()).map(|column| { + let side = crate::flow::flow_side_palette(column.side, cx); + crate::flow::FlowSidePalette { + base: sheet_type_color(side.base, cx.theme().sidebar), + foreground: cx.theme().sidebar_accent_foreground, + hover: side.hover.opacity(0.16), + active: cx.theme().sidebar_accent.mix(side.active, 0.36), + } + }); + let sheet_color = sheet_palette.map(|palette| palette.base); let toggle_editor = editor.clone(); let activate_editor = editor.clone(); let key = sheet_id.as_u128() as usize; @@ -166,7 +178,9 @@ impl Workspace { is_active: active_sheet == Some(sheet_id) && active_cell.is_none(), has_search_match: false, guide: OutlineRowGuides::default(), - hierarchy_color: None, + hierarchy_color: sheet_color, + label_color: None, + interaction_palette: sheet_palette, guide_colors: None, incoming_branch: None, icon: None, @@ -201,6 +215,7 @@ impl Workspace { nav_width, window, &[0], + sheet_color, true, cx, &mut rows, @@ -210,6 +225,7 @@ impl Workspace { v_flex() .size_full() .h_full() + .gap_1() .p_2() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) @@ -228,6 +244,7 @@ fn append_flow_outline_cell_rows( nav_width: Pixels, window: &mut Window, ancestor_depths: &[usize], + sheet_color: Option, is_final_child: bool, cx: &mut Context, rows: &mut Vec, @@ -246,19 +263,14 @@ fn append_flow_outline_cell_rows( .map(|candidate| candidate.id) .collect(); let expanded = editor.read(cx).outline_item_expanded(cell_id); - let side_color = match definition.columns[column_depth].side { - flowstate_flow::ArgumentSide::One => cx.theme().primary, - flowstate_flow::ArgumentSide::Two => cx.theme().info, - }; + let side_palette = crate::flow::flow_side_palette(definition.columns[column_depth].side, cx); + let side_color = side_palette.base; let guide_colors = (0..depth) .map(|guide_depth| { if guide_depth == 0 { - outline_hierarchy_color(0, cx) + sheet_color.unwrap_or_else(|| outline_hierarchy_color(0, cx)) } else { - match definition.columns[guide_depth - 1].side { - flowstate_flow::ArgumentSide::One => cx.theme().primary, - flowstate_flow::ArgumentSide::Two => cx.theme().info, - } + crate::flow::flow_side_palette(definition.columns[guide_depth - 1].side, cx).base } }) .collect(); @@ -295,6 +307,8 @@ fn append_flow_outline_cell_rows( has_search_match: false, guide, hierarchy_color: Some(side_color), + label_color: None, + interaction_palette: Some(side_palette), guide_colors: Some(guide_colors), incoming_branch, icon: None, @@ -327,6 +341,7 @@ fn append_flow_outline_cell_rows( nav_width, window, &child_ancestors, + sheet_color, index + 1 == children.len(), cx, rows, @@ -351,6 +366,8 @@ struct SidebarTreeRow { has_search_match: bool, guide: OutlineRowGuides, hierarchy_color: Option, + label_color: Option, + interaction_palette: Option, guide_colors: Option>, incoming_branch: Option, icon: Option, @@ -370,6 +387,8 @@ struct IncomingBranch { fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut App) -> ListItem { let hierarchy_color = row.hierarchy_color.unwrap_or_else(|| outline_hierarchy_color(row.depth, cx)); + let label_color = row.label_color.unwrap_or(hierarchy_color); + let interaction_palette = row.interaction_palette; let guide_colors = row.guide_colors; let guide_depths = row.guide.ancestor_depths; let incoming_branch = row.incoming_branch; @@ -555,9 +574,9 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .px_1() .overflow_hidden() .text_color(if row.is_active { - cx.theme().sidebar_accent_foreground + interaction_palette.map_or(cx.theme().sidebar_accent_foreground, |palette| palette.foreground) } else { - hierarchy_color + label_color }) .whitespace_nowrap() .rounded(cx.theme().radius) @@ -581,18 +600,20 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .left_0() .right_0() .bottom_0() - .bg( - cx.theme() - .sidebar_accent - .opacity(if row.has_search_match { 0.55 } else { 1.0 }), - ) + .bg(interaction_palette.map_or(cx.theme().sidebar_accent, |palette| palette.active).opacity(if row.has_search_match { + 0.55 + } else { + 1.0 + })) .border_1() .border_color(hierarchy_color) .rounded(cx.theme().radius), ) }) .when(!row.is_active && !row.has_search_match, |this| { - this.hover(|style| style.bg(cx.theme().list_hover)) + this.hover(|style| { + style.bg(interaction_palette.map_or(cx.theme().list_hover, |palette| palette.hover.opacity(0.14))) + }) }) .child(label) .on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -629,4 +650,37 @@ fn outline_hierarchy_color(depth: usize, cx: &App) -> Hsla { } } +fn sheet_type_color(side: Hsla, background: Hsla) -> Hsla { + let mut color = Hsla { + s: (side.s * 0.70).clamp(0.46, 0.78), + l: if background.l < 0.5 { + (side.l + 0.18).clamp(0.52, 0.78) + } else { + (side.l - 0.18).clamp(0.22, 0.48) + }, + ..side + }; + if contrast_ratio(color, background) < 3.0 { + color.l = if background.l < 0.5 { 0.72 } else { 0.28 }; + } + color +} + +fn contrast_ratio(foreground: Hsla, background: Hsla) -> f32 { + let luminance = |color: Hsla| { + let rgb = color.to_rgb(); + let linear = |channel: f32| { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b) + }; + let foreground = luminance(foreground); + let background = luminance(background); + (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) +} + From 9b7b971bb9ac0b4d6b9b70837f70441461e4af8f Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sat, 13 Jun 2026 21:10:21 -0500 Subject: [PATCH 06/24] Flowing works --- Cargo.lock | 2 + crates/flowstate-docx/src/exporter/blocks.rs | 5 +- crates/flowstate-flow/src/document.rs | 132 +-- crates/flowstate-flow/src/operations.rs | 257 +++++- crates/flowstate/Cargo.toml | 1 + crates/flowstate/assets/icons/file-input.svg | 1 + .../assets/icons/message-square-reply.svg | 1 + crates/flowstate/src/app.rs | 8 + crates/flowstate/src/flow/cell_theme.rs | 156 ++++ crates/flowstate/src/flow/editor.rs | 797 ++++++++---------- .../flowstate/src/flow/editor/annotation.rs | 196 +++++ .../flowstate/src/flow/editor/cell_editing.rs | 138 +++ crates/flowstate/src/flow/editor/drag_drop.rs | 140 +++ crates/flowstate/src/flow/editor/layout.rs | 95 +++ crates/flowstate/src/flow/mod.rs | 1 + crates/flowstate/src/flow/ribbon.rs | 65 +- .../src/workspace/workspace/keybindings.rs | 15 +- .../src/rich_text/benchmarks/layout.rs | 3 +- .../src/rich_text/editor/edit_pipeline.rs | 2 +- .../gpui-flowtext/src/rich_text/editor/mod.rs | 8 +- .../src/rich_text/editor/mouse.rs | 9 +- .../src/rich_text/editor/traits.rs | 11 +- crates/gpui-flowtext/src/rich_text/element.rs | 11 +- .../src/rich_text/invisibility.rs | 21 +- .../src/rich_text/layout/prep.rs | 10 +- crates/gpui-flowtext/src/rich_text/paint.rs | 3 +- 26 files changed, 1499 insertions(+), 589 deletions(-) create mode 100644 crates/flowstate/assets/icons/file-input.svg create mode 100644 crates/flowstate/assets/icons/message-square-reply.svg create mode 100644 crates/flowstate/src/flow/cell_theme.rs create mode 100644 crates/flowstate/src/flow/editor/annotation.rs create mode 100644 crates/flowstate/src/flow/editor/cell_editing.rs create mode 100644 crates/flowstate/src/flow/editor/drag_drop.rs create mode 100644 crates/flowstate/src/flow/editor/layout.rs diff --git a/Cargo.lock b/Cargo.lock index 92155a1a..131c2c5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2663,6 +2663,7 @@ dependencies = [ "hotpath", "memchr", "mimalloc", + "palette", "rustc-hash 2.1.2", "serde", "serde_json", @@ -6301,6 +6302,7 @@ dependencies = [ "fast-srgb8", "libm", "palette_derive", + "phf 0.11.3", ] [[package]] diff --git a/crates/flowstate-docx/src/exporter/blocks.rs b/crates/flowstate-docx/src/exporter/blocks.rs index ec2698eb..838f9f90 100644 --- a/crates/flowstate-docx/src/exporter/blocks.rs +++ b/crates/flowstate-docx/src/exporter/blocks.rs @@ -39,7 +39,10 @@ fn export_paragraph_with_text(paragraph: &Paragraph, text: &str, theme: &Documen let mut byte = 0usize; for run in ¶graph.runs { let start = byte.min(text.len()); - let end = (byte + run.len).min(text.len()).max(start); + let end = text[start..] + .char_indices() + .nth(run.len) + .map_or(text.len(), |(i, _)| start + i); out = add_text_run(out, &text[start..end], run.styles, paragraph.style, theme); byte = end; } diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index 16f77728..b3718e4e 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -113,7 +113,7 @@ impl Cell { let document = flowstate_document::document_from_input( flowstate_document::flowstate_document_theme(), vec![flowstate_document::InputParagraph { - style: flowstate_document::ParagraphStyle::Normal, + style: flowstate_document::PARAGRAPH_TAG, runs: vec![flowstate_document::InputRun { text: String::new(), styles: flowstate_document::RunStyles::default(), @@ -139,61 +139,51 @@ impl Cell { pub fn summary_text(&self) -> std::io::Result { let document = self.document()?; - if let Some((index, _)) = document - .paragraphs - .iter() - .enumerate() - .find(|(_, paragraph)| paragraph.style == flowstate_document::PARAGRAPH_ANALYTIC) - { - return Ok(paragraph_text(&document, index)); - } - if let Some((tag_index, _)) = document - .paragraphs - .iter() - .enumerate() - .find(|(_, paragraph)| paragraph.style == flowstate_document::PARAGRAPH_TAG) - { - let mut summary = paragraph_text(&document, tag_index); - for (index, paragraph) in document.paragraphs.iter().enumerate().skip(tag_index + 1) { - if matches!( - paragraph.style, - flowstate_document::PARAGRAPH_TAG - | flowstate_document::PARAGRAPH_ANALYTIC - | flowstate_document::PARAGRAPH_BLOCK - | flowstate_document::PARAGRAPH_HAT - | flowstate_document::PARAGRAPH_POCKET - ) { - break; - } - let text = paragraph_text(&document, index); - let mut offset = 0; - for run in ¶graph.runs { - let end = offset + run.len; - if run.styles.semantic == flowstate_document::SEMANTIC_CITE { - if !summary.is_empty() { - summary.push('\n'); - } - summary.push_str(&text[offset..end]); - } - offset = end; - } - if summary.contains('\n') { - break; + let mut projection = Vec::new(); + for (index, paragraph) in document.paragraphs.iter().enumerate() { + if matches!( + paragraph.style, + flowstate_document::PARAGRAPH_TAG + | flowstate_document::PARAGRAPH_UNDERTAG + | flowstate_document::PARAGRAPH_ANALYTIC + ) { + projection.push(paragraph_text(&document, index)); + continue; + } + + let text = paragraph_text(&document, index); + let mut cite_text = String::new(); + let mut offset = 0; + for run in ¶graph.runs { + let end = offset + run.len; + if run.styles.semantic == flowstate_document::SEMANTIC_CITE { + cite_text.push_str(&text[offset..end]); } + offset = end; } - return Ok(summary); + if !cite_text.is_empty() { + projection.push(cite_text); + } + } + if !projection.is_empty() { + return Ok(projection.join("\n")); } Ok(document.text.to_string()) } pub fn uses_summary_projection(&self) -> std::io::Result { let document = self.document()?; - Ok( - document - .paragraphs + Ok(document.paragraphs.iter().any(|paragraph| { + matches!( + paragraph.style, + flowstate_document::PARAGRAPH_TAG + | flowstate_document::PARAGRAPH_UNDERTAG + | flowstate_document::PARAGRAPH_ANALYTIC + ) || paragraph + .runs .iter() - .any(|paragraph| matches!(paragraph.style, flowstate_document::PARAGRAPH_TAG | flowstate_document::PARAGRAPH_ANALYTIC)), - ) + .any(|run| run.styles.semantic == flowstate_document::SEMANTIC_CITE) + })) } } @@ -701,6 +691,54 @@ fn merge_annotations_from_loro(projection: &mut FlowProjection, loro: &LoroDoc) mod tests { use super::*; + fn cell_with_paragraphs(paragraphs: Vec) -> Cell { + let document = flowstate_document::document_from_input(flowstate_document::flowstate_document_theme(), paragraphs); + Cell { + id: Uuid::new_v4(), + column_id: Uuid::new_v4(), + parent_id: None, + document_bytes: flowstate_document::db8_bytes(&document).unwrap(), + } + } + + fn run(text: &str) -> flowstate_document::InputRun { + flowstate_document::InputRun { + text: text.into(), + styles: flowstate_document::RunStyles::default(), + } + } + + fn cite(text: &str) -> flowstate_document::InputRun { + let mut run = run(text); + run.styles.semantic = flowstate_document::SEMANTIC_CITE; + run + } + + #[test] + fn summary_projects_mixed_card_and_analytic_content_in_document_order() { + let cell = cell_with_paragraphs(vec![ + flowstate_document::InputParagraph { + style: flowstate_document::PARAGRAPH_TAG, + runs: vec![run("Tag")], + }, + flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![run("hidden before "), cite("Cite"), run(" hidden after")], + }, + flowstate_document::InputParagraph { + style: flowstate_document::PARAGRAPH_UNDERTAG, + runs: vec![run("Undertag")], + }, + flowstate_document::InputParagraph { + style: flowstate_document::PARAGRAPH_ANALYTIC, + runs: vec![run("Analytic")], + }, + ]); + + assert_eq!(cell.summary_text().unwrap(), "Tag\nCite\nUndertag\nAnalytic"); + assert!(cell.uses_summary_projection().unwrap()); + } + #[test] fn round_trips_loro_snapshot() { let mut document = FlowDocument::new(); diff --git a/crates/flowstate-flow/src/operations.rs b/crates/flowstate-flow/src/operations.rs index 078aea7d..6a64046d 100644 --- a/crates/flowstate-flow/src/operations.rs +++ b/crates/flowstate-flow/src/operations.rs @@ -10,6 +10,28 @@ pub enum RelativePosition { } impl FlowDocument { + pub fn child_append_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let parent_index = sheet.cells.iter().position(|cell| cell.id == parent_id).context("unknown cell")?; + Ok( + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent_id)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1, + ) + } + + pub fn child_prepend_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let parent_index = sheet.cells.iter().position(|cell| cell.id == parent_id).context("unknown cell")?; + Ok(parent_index + 1) + } + pub fn deletion_fallback(&self, sheet_id: SheetId, cell_id: CellId) -> Option { let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)?; let definition = self.projection().format.sheet_type(sheet.sheet_type_id)?; @@ -108,6 +130,10 @@ impl FlowDocument { Ok(id) } + pub fn add_orphan_at_column_top(&mut self, sheet_id: SheetId, column_index: usize) -> anyhow::Result { + self.add_plain_cell(sheet_id, column_index, None, Some(0)) + } + pub fn add_sibling(&mut self, sheet_id: SheetId, cell_id: CellId, position: RelativePosition) -> anyhow::Result { let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; let index = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; @@ -130,11 +156,20 @@ impl FlowDocument { if child_column >= definition.columns.len() { bail!("rightmost cells cannot receive responses"); } - let insertion = sheet - .cells - .iter() - .position(|cell| cell.parent_id == Some(parent_id)) - .unwrap_or(sheet.cells.len()); + let insertion = self.child_append_index(sheet_id, parent_id)?; + self.add_plain_cell(sheet_id, child_column, Some(parent_id), Some(insertion)) + } + + pub fn add_first_response(&mut self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let parent = sheet.cells.iter().find(|cell| cell.id == parent_id).context("unknown cell")?; + let definition = self.projection().format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + let parent_column = definition.columns.iter().position(|column| column.id == parent.column_id).context("unknown column")?; + let child_column = parent_column + 1; + if child_column >= definition.columns.len() { + bail!("rightmost cells cannot receive responses"); + } + let insertion = self.child_prepend_index(sheet_id, parent_id)?; self.add_plain_cell(sheet_id, child_column, Some(parent_id), Some(insertion)) } @@ -168,11 +203,14 @@ impl FlowDocument { .context("unknown cell")?; let mut document = cell.document()?; let paragraphs = std::sync::Arc::make_mut(&mut document.paragraphs); + let struck = paragraphs.iter().flat_map(|paragraph| ¶graph.runs).all(|run| run.styles.strikethrough); for paragraph in paragraphs { for run in &mut paragraph.runs { - run.styles.strikethrough = true; + run.styles.strikethrough = !struck; } + paragraph.version = paragraph.version.wrapping_add(1); } + document.blocks = std::sync::Arc::new(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs)); cell.document_bytes = flowstate_document::db8_bytes(&document)?; Ok(()) }) @@ -200,6 +238,42 @@ impl FlowDocument { }) } + pub fn ensure_cell_editable_projection(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result { + let cell = self + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .cells + .iter() + .find(|cell| cell.id == cell_id) + .context("unknown cell")?; + if cell.uses_summary_projection()? { + return Ok(false); + } + self.update(|projection| { + let cell = projection + .sheets + .iter_mut() + .find(|sheet| sheet.id == sheet_id) + .context("unknown sheet")? + .cells + .iter_mut() + .find(|cell| cell.id == cell_id) + .context("unknown cell")?; + let mut document = cell.document()?; + let paragraphs = std::sync::Arc::make_mut(&mut document.paragraphs); + let paragraph = paragraphs.first_mut().context("cell document has no paragraph")?; + paragraph.style = flowstate_document::PARAGRAPH_TAG; + paragraph.version = paragraph.version.wrapping_add(1); + document.blocks = std::sync::Arc::new(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs)); + cell.document_bytes = flowstate_document::db8_bytes(&document)?; + Ok(()) + })?; + Ok(true) + } + pub fn move_cell( &mut self, sheet_id: SheetId, @@ -214,40 +288,14 @@ impl FlowDocument { let column_id = definition.columns.get(target_column).context("column index out of range")?.id; let source = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; let mut cell = sheet.cells.remove(source); - let source_column = definition - .columns - .iter() - .position(|column| column.id == cell.column_id) - .context("unknown source column")?; - let delta = target_column as isize - source_column as isize; cell.column_id = column_id; cell.parent_id = new_parent; - let mut moving_parents = vec![cell_id]; - while let Some(parent_id) = moving_parents.pop() { - let child_ids: Vec<_> = sheet - .cells - .iter() - .filter(|descendant| descendant.parent_id == Some(parent_id)) - .map(|descendant| descendant.id) - .collect(); - for child_id in child_ids { - let descendant = sheet.cells.iter_mut().find(|candidate| candidate.id == child_id).context("missing descendant")?; - let descendant_column = definition - .columns - .iter() - .position(|column| column.id == descendant.column_id) - .context("unknown descendant column")?; - let destination = descendant_column as isize + delta; - if let Ok(destination) = usize::try_from(destination) - && let Some(column) = definition.columns.get(destination) - { - descendant.column_id = column.id; - moving_parents.push(child_id); - } else { - descendant.parent_id = None; - } + for child in &mut sheet.cells { + if child.parent_id == Some(cell_id) { + child.parent_id = None; } } + let target_index = target_index.saturating_sub(usize::from(source < target_index)); sheet.cells.insert(target_index.min(sheet.cells.len()), cell); Ok(()) }) @@ -277,6 +325,15 @@ impl FlowDocument { }) } + pub fn clear_all_annotations(&mut self, originator: &AnnotationOriginator) -> anyhow::Result<()> { + self.update(|projection| { + for sheet in &mut projection.sheets { + sheet.annotations.retain(|stroke| &stroke.originator != originator); + } + Ok(()) + }) + } + pub fn delete_annotation(&mut self, sheet_id: SheetId, stroke_id: uuid::Uuid, originator: &AnnotationOriginator) -> anyhow::Result { let mut removed = false; self.update(|projection| { @@ -290,6 +347,17 @@ impl FlowDocument { } } +fn is_descendant_of(sheet: &Sheet, cell_id: CellId, ancestor_id: CellId) -> bool { + let mut parent = sheet.cells.iter().find(|cell| cell.id == cell_id).and_then(|cell| cell.parent_id); + while let Some(parent_id) = parent { + if parent_id == ancestor_id { + return true; + } + parent = sheet.cells.iter().find(|cell| cell.id == parent_id).and_then(|cell| cell.parent_id); + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -311,6 +379,53 @@ mod tests { assert_eq!(document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().parent_id, None); } + #[test] + fn moving_cell_down_uses_pre_removal_insertion_index() { + let (mut document, sheet_id) = document_with_sheet(); + let first = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); + let second = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); + let third = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); + + document.move_cell(sheet_id, first, 0, 2, None).unwrap(); + + let ids: Vec<_> = document.projection().sheets[0].cells.iter().map(|cell| cell.id).collect(); + assert_eq!(ids, vec![second, first, third]); + } + + #[test] + fn unsupported_cell_content_becomes_an_editable_tag_without_losing_text() { + let (mut document, sheet_id) = document_with_sheet(); + let cell_id = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); + let source = flowstate_document::document_from_input( + flowstate_document::flowstate_document_theme(), + vec![flowstate_document::InputParagraph { + style: flowstate_document::ParagraphStyle::Normal, + runs: vec![flowstate_document::InputRun { + text: "Preserve me".into(), + styles: flowstate_document::RunStyles::default(), + }], + }], + ); + document.replace_cell_document(sheet_id, cell_id, &source).unwrap(); + + assert!(document.ensure_cell_editable_projection(sheet_id, cell_id).unwrap()); + let cell = &document.projection().sheets[0].cells[0]; + assert_eq!(cell.summary_text().unwrap(), "Preserve me"); + assert_eq!(cell.document().unwrap().paragraphs[0].style, flowstate_document::PARAGRAPH_TAG); + } + + #[test] + fn quick_responses_append_after_existing_children() { + let (mut document, sheet_id) = document_with_sheet(); + let parent = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); + let first = document.add_response(sheet_id, parent).unwrap(); + let grandchild = document.add_response(sheet_id, first).unwrap(); + let second = document.add_response(sheet_id, parent).unwrap(); + + let ids: Vec<_> = document.projection().sheets[0].cells.iter().map(|cell| cell.id).collect(); + assert_eq!(ids, vec![parent, first, grandchild, second]); + } + #[test] fn deletion_fallback_prefers_previous_sibling_then_parent() { let (mut document, sheet) = document_with_sheet(); @@ -343,14 +458,21 @@ mod tests { } #[test] - fn moving_parent_carries_valid_descendants() { + fn moving_parent_orphans_children_and_leaves_them_in_place() { let (mut document, sheet) = document_with_sheet(); let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); let child = document.add_response(sheet, parent).unwrap(); + let grandchild = document.add_response(sheet, child).unwrap(); + let child_column = document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().column_id; + let grandchild_column = document.projection().sheets[0].cells.iter().find(|cell| cell.id == grandchild).unwrap().column_id; document.move_cell(sheet, parent, 1, 0, None).unwrap(); let sheet = &document.projection().sheets[0]; - let definition = &document.projection().format.sheet_types[0]; - assert_eq!(sheet.cells.iter().find(|cell| cell.id == child).unwrap().column_id, definition.columns[2].id); + let child = sheet.cells.iter().find(|cell| cell.id == child).unwrap(); + let grandchild = sheet.cells.iter().find(|cell| cell.id == grandchild).unwrap(); + assert_eq!(child.column_id, child_column); + assert_eq!(child.parent_id, None); + assert_eq!(grandchild.column_id, grandchild_column); + assert_eq!(grandchild.parent_id, Some(child.id)); } #[test] @@ -364,6 +486,44 @@ mod tests { assert_eq!(document.projection().sheets[0].cells.len(), 1); } + #[test] + fn newly_added_top_orphan_precedes_every_existing_run() { + let (mut document, sheet) = document_with_sheet(); + let first = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let second = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let newest = document.add_orphan_at_column_top(sheet, 1).unwrap(); + let cells = &document.projection().sheets[0].cells; + assert_eq!(cells.iter().map(|cell| cell.id).collect::>(), vec![newest, first, second]); + } + + #[test] + fn striking_cell_updates_all_serialized_paragraph_runs() { + let (mut document, sheet) = document_with_sheet(); + let cell = document.add_plain_cell(sheet, 0, None, None).unwrap(); + document.strike_cell(sheet, cell).unwrap(); + let cell = &document.projection().sheets[0].cells[0]; + let rich_text = cell.document().unwrap(); + assert!(rich_text.paragraphs.iter().flat_map(|paragraph| ¶graph.runs).all(|run| run.styles.strikethrough)); + assert!(rich_text.blocks.iter().all(|block| match block { + flowstate_document::Block::Paragraph(paragraph) => paragraph.runs.iter().all(|run| run.styles.strikethrough), + _ => true, + })); + document.strike_cell(sheet, cell.id).unwrap(); + let rich_text = document.projection().sheets[0].cells[0].document().unwrap(); + assert!(rich_text.paragraphs.iter().flat_map(|paragraph| ¶graph.runs).all(|run| !run.styles.strikethrough)); + } + + #[test] + fn first_response_precedes_existing_child_subtrees() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let existing = document.add_response(sheet, parent).unwrap(); + let grandchild = document.add_response(sheet, existing).unwrap(); + let first = document.add_first_response(sheet, parent).unwrap(); + let ids = document.projection().sheets[0].cells.iter().map(|cell| cell.id).collect::>(); + assert_eq!(ids, vec![parent, first, existing, grandchild]); + } + #[test] fn concurrent_annotation_adds_merge_additively() { let (base, sheet) = document_with_sheet(); @@ -380,6 +540,23 @@ mod tests { assert_eq!(two.projection().sheets[0].annotations.len(), 2); } + #[test] + fn clearing_annotations_removes_only_originators_own_strokes_across_sheets() { + let (mut document, first_sheet) = document_with_sheet(); + let sheet_type = document.projection().format.sheet_types[0].id; + let second_sheet = document.create_sheet("Other", sheet_type).unwrap(); + for sheet in [first_sheet, second_sheet] { + document.add_annotation(sheet, test_stroke(sheet, "local")).unwrap(); + document.add_annotation(sheet, test_stroke(sheet, "collaborator")).unwrap(); + } + + document.clear_all_annotations(&AnnotationOriginator("local".into())).unwrap(); + + assert!(document.projection().sheets.iter().all(|sheet| { + sheet.annotations.len() == 1 && sheet.annotations[0].originator == AnnotationOriginator("collaborator".into()) + })); + } + #[test] fn concurrent_cell_adds_merge_by_stable_id() { let (base, sheet) = document_with_sheet(); @@ -407,7 +584,7 @@ mod tests { originator: AnnotationOriginator(originator.into()), points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 2.0, y: 2.0 }], style: StrokeStyle { - color_rgba: 0xff00_0000, + color_rgba: 0xff00_00ff, width: 2.0, opacity: 0.5, }, diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index f1c8b327..f7bebbc6 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -23,6 +23,7 @@ gpui-component-assets = { workspace = true } hotpath = { workspace = true } memchr = "2.8.1" mimalloc = { workspace = true } +palette = "0.7.6" rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/flowstate/assets/icons/file-input.svg b/crates/flowstate/assets/icons/file-input.svg new file mode 100644 index 00000000..39f81c6b --- /dev/null +++ b/crates/flowstate/assets/icons/file-input.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/assets/icons/message-square-reply.svg b/crates/flowstate/assets/icons/message-square-reply.svg new file mode 100644 index 00000000..70d8f873 --- /dev/null +++ b/crates/flowstate/assets/icons/message-square-reply.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/src/app.rs b/crates/flowstate/src/app.rs index e5e598c0..70b67641 100644 --- a/crates/flowstate/src/app.rs +++ b/crates/flowstate/src/app.rs @@ -353,6 +353,8 @@ impl AssetSource for AppAssets { "icons/underline.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/underline.svg")))), "icons/archive.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/archive.svg")))), "icons/file-search-corner.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/file-search-corner.svg")))), + "icons/file-input.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/file-input.svg")))), + "icons/message-square-reply.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/message-square-reply.svg")))), "icons/notebook-text.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/notebook-text.svg")))), "icons/table-of-contents.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/table-of-contents.svg")))), "icons/panel-top-open.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/panel-top-open.svg")))), @@ -413,6 +415,12 @@ impl AssetSource for AppAssets { if "icons/file-search-corner.svg".starts_with(path) { assets.push("icons/file-search-corner.svg".into()); } + if "icons/file-input.svg".starts_with(path) { + assets.push("icons/file-input.svg".into()); + } + if "icons/message-square-reply.svg".starts_with(path) { + assets.push("icons/message-square-reply.svg".into()); + } if "icons/notebook-text.svg".starts_with(path) { assets.push("icons/notebook-text.svg".into()); } diff --git a/crates/flowstate/src/flow/cell_theme.rs b/crates/flowstate/src/flow/cell_theme.rs new file mode 100644 index 00000000..6592075f --- /dev/null +++ b/crates/flowstate/src/flow/cell_theme.rs @@ -0,0 +1,156 @@ +use flowstate_document::{Document, DocumentTheme}; +use gpui::{Hsla, transparent_black}; +use palette::{FromColor as _, LinSrgb, Oklch, Srgb}; + +pub(super) fn apply_flow_cell_theme(document: &mut Document, client_theme: &DocumentTheme, foreground: Hsla, background: Hsla) { + document.theme = client_theme.clone(); + let source_default = document.theme.default_text_color; + document.theme.default_text_color = foreground; + document.theme.document_background_color = transparent_black(); + document.theme.pageless_inset_x = gpui::px(0.0); + document.theme.pageless_inset_top = gpui::px(0.0); + document.theme.pageless_inset_bottom = gpui::px(0.0); + document.theme.invisibility_visible_paragraph_styles.clear(); + for slot in [3, 4, 6] { + document.theme.invisibility_visible_paragraph_styles.insert(slot); + } + document.theme.invisibility_visible_semantic_styles.clear(); + document.theme.invisibility_visible_semantic_styles.insert(1); + document.theme.invisibility_visible_highlight_styles.clear(); + document.theme.default_highlight_color = transform_color(document.theme.default_highlight_color, source_default, foreground, background, false); + + for style in document.theme.custom_paragraph_styles.values_mut() { + style.color = transform_color(style.color, source_default, foreground, background, true); + } + for style in document.theme.custom_semantic_styles.values_mut() { + if let Some(color) = style.color.as_mut() { + *color = transform_color(*color, source_default, foreground, background, true); + } + } + for style in document.theme.custom_highlight_styles.values_mut() { + style.color = transform_color(style.color, source_default, foreground, background, false); + } +} + +fn transform_color(source: Hsla, source_default: Hsla, target_default: Hsla, background: Hsla, enforce_text_contrast: bool) -> Hsla { + let source = to_oklch(source); + let source_default = to_oklch(source_default); + let target_default = to_oklch(target_default); + let transformed = Oklch::new( + (target_default.0.l + source.0.l - source_default.0.l).clamp(0.0, 1.0), + (target_default.0.chroma + source.0.chroma - source_default.0.chroma).max(0.0), + target_default.0.hue + (source.0.hue - source_default.0.hue), + ); + let transformed = from_oklch(transformed, source.1); + if enforce_text_contrast { + ensure_contrast(transformed, background, 3.0) + } else { + transformed + } +} + +fn to_oklch(color: Hsla) -> (Oklch, f32) { + let rgb = color.to_rgb(); + let linear: LinSrgb = Srgb::new(rgb.r, rgb.g, rgb.b).into_linear(); + (Oklch::from_color(linear), color.a) +} + +fn from_oklch(color: Oklch, alpha: f32) -> Hsla { + let rgb: Srgb = LinSrgb::from_color(color).into_encoding(); + Hsla::from(gpui::Rgba { + r: rgb.red.clamp(0.0, 1.0), + g: rgb.green.clamp(0.0, 1.0), + b: rgb.blue.clamp(0.0, 1.0), + a: alpha, + }) +} + +fn contrast_ratio(foreground: Hsla, background: Hsla) -> f32 { + let luminance = |color: Hsla| { + let rgb = color.to_rgb(); + let linear = |channel: f32| { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b) + }; + let foreground = luminance(foreground); + let background = luminance(background); + (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) +} + +fn ensure_contrast(color: Hsla, background: Hsla, minimum_ratio: f32) -> Hsla { + if color.a <= 0.5 || contrast_ratio(color, background) >= minimum_ratio { + return color; + } + let mut dark = color; + dark.l = 0.0; + let mut light = color; + light.l = 1.0; + let target_lightness = if contrast_ratio(light, background) >= contrast_ratio(dark, background) { + 1.0 + } else { + 0.0 + }; + let mut lower = 0.0; + let mut upper = 1.0; + for _ in 0..16 { + let amount = (lower + upper) / 2.0; + let mut candidate = color; + candidate.l = color.l + (target_lightness - color.l) * amount; + if contrast_ratio(candidate, background) >= minimum_ratio { + upper = amount; + } else { + lower = amount; + } + } + let mut adjusted = color; + adjusted.l = color.l + (target_lightness - color.l) * upper; + adjusted +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::rgba; + + #[test] + fn transformed_style_colors_preserve_alpha() { + let source = Hsla::from(rgba(0x3366_994d)); + let transformed = transform_color( + source, + Hsla::from(rgba(0x0000_00ff)), + Hsla::from(rgba(0xeeee_eeff)), + Hsla::from(rgba(0x1111_11ff)), + false, + ); + assert!((transformed.a - source.a).abs() < f32::EPSILON); + } + + #[test] + fn transformed_opaque_text_remains_readable() { + let background = Hsla::from(rgba(0xf8f8_f8ff)); + let transformed = transform_color( + Hsla::from(rgba(0x5555_55ff)), + Hsla::from(rgba(0x0000_00ff)), + Hsla::from(rgba(0xf0f0_f0ff)), + background, + true, + ); + assert!(contrast_ratio(transformed, background) >= 3.0); + } + + #[test] + fn transformed_style_hues_remain_distinct_with_achromatic_theme_text() { + let source_default = Hsla::from(rgba(0x0000_00ff)); + let target_default = Hsla::from(rgba(0xffff_ffff)); + let background = Hsla::from(rgba(0x1111_11ff)); + let one = transform_color(Hsla::from(rgba(0x2563_ebff)), source_default, target_default, background, true).to_rgb(); + let two = transform_color(Hsla::from(rgba(0xdc26_26ff)), source_default, target_default, background, true).to_rgb(); + let distance = (one.r - two.r).abs() + (one.g - two.g).abs() + (one.b - two.b).abs(); + assert!(distance > 0.25); + } +} diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index b204879f..9861c771 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -2,18 +2,32 @@ use std::path::PathBuf; use std::collections::HashSet; use flowstate_flow::{ - AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowDocument, RelativePosition, SheetId, StrokeStyle, VersionVector, + AnnotationOriginator, BoardPoint, BoardRect, CellId, FlowDocument, RelativePosition, SheetId, VersionVector, }; use gpui::{ AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, KeyDownEvent, KeyUpEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Render, SharedString, Subscription, Task, Window, canvas, div, point, - prelude::*, px, rgba, ScrollHandle, + prelude::*, px, rgba, ScrollHandle, ScrollWheelEvent, }; +use gpui_component::{Icon, IconName, Sizable as _}; +use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::ActiveTheme as _; use gpui_component::PixelsExt as _; use gpui_component::scroll::{Scrollbar, ScrollbarShow}; -use crate::{flow::flow_side_palette, rich_text_element::RichTextEditor}; +use crate::{ + app_settings::load_document_theme, + flow::{cell_theme::apply_flow_cell_theme, flow_side_palette}, + rich_text_element::{RichTextDocumentElement, RichTextEditor}, +}; + +mod annotation; +mod cell_editing; +mod drag_drop; +mod layout; + +use drag_drop::{CellDropDestination, FlowCellDrag, FlowCellDragPreview}; +use layout::sheet_cell_layout; #[derive(Clone, Debug)] pub enum FlowEditorEvent { @@ -30,26 +44,6 @@ pub enum AnnotationTool { Eraser, } -#[derive(Clone)] -struct FlowCellDrag { - cell_id: CellId, -} - -struct FlowCellDragPreview; - -impl Render for FlowCellDragPreview { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .px_2() - .py_1() - .rounded(cx.theme().radius) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .child("Move argument") - } -} - pub struct FlowEditor { document: FlowDocument, path: Option, @@ -63,8 +57,9 @@ pub struct FlowEditor { local_annotation_originator: AnnotationOriginator, drawing_points: Vec, cell_editors: std::collections::HashMap>, - cell_editor_subscriptions: Vec, - pending_cell_drop: Option<(CellId, RelativePosition)>, + cell_editor_themes: std::collections::HashMap, + cell_editor_subscriptions: std::collections::HashMap, + pending_cell_drop: Option, cell_bounds: std::collections::HashMap>, board_scroll: ScrollHandle, board_zoom: f32, @@ -98,7 +93,8 @@ impl FlowEditor { local_annotation_originator: AnnotationOriginator("local".into()), drawing_points: Vec::new(), cell_editors: std::collections::HashMap::new(), - cell_editor_subscriptions: Vec::new(), + cell_editor_themes: std::collections::HashMap::new(), + cell_editor_subscriptions: std::collections::HashMap::new(), pending_cell_drop: None, cell_bounds: std::collections::HashMap::new(), board_scroll: ScrollHandle::new(), @@ -141,6 +137,16 @@ impl FlowEditor { self.active_cell } + pub fn focus_active_cell(&mut self, window: &mut Window, cx: &mut Context) { + cx.on_next_frame(window, |flow, window, cx| { + let Some(editor) = flow.active_cell.and_then(|cell| flow.cell_editors.get(&cell)).cloned() else { + return; + }; + editor.update(cx, |editor, cx| editor.move_document_start(cx)); + editor.read(cx).focus_handle(cx).focus(window); + }); + } + pub fn annotation_tool(&self) -> AnnotationTool { self.annotation_tool } @@ -177,133 +183,6 @@ impl FlowEditor { } } - pub fn set_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { - self.annotation_tool = tool; - cx.notify(); - } - - pub fn toggle_annotations_visible(&mut self, cx: &mut Context) { - let Some(sheet) = self.active_sheet else { - return; - }; - if !self.hidden_annotation_sheets.remove(&sheet) { - self.hidden_annotation_sheets.insert(sheet); - } - cx.notify(); - } - - pub fn set_originator_annotations_hidden(&mut self, originator: AnnotationOriginator, hidden: bool, cx: &mut Context) { - if hidden { - self.hidden_annotation_originators.insert(originator); - } else { - self.hidden_annotation_originators.remove(&originator); - } - cx.notify(); - } - - pub fn clear_annotations(&mut self, cx: &mut Context) { - let Some(sheet) = self.active_sheet else { - return; - }; - if self.document.clear_annotations(sheet, &self.local_annotation_originator).is_ok() { - self.changed(self.active_cell, cx); - } - } - - fn annotation_point(&self, position: gpui::Point) -> BoardPoint { - let offset = self.board_scroll.offset(); - BoardPoint { - x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom - offset.x.as_f32(), - y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom - offset.y.as_f32(), - } - } - - fn begin_annotation(&mut self, position: gpui::Point, cx: &mut Context) { - match self.annotation_tool { - AnnotationTool::Marker => { - self.drawing_points.clear(); - self.drawing_points.push(self.annotation_point(position)); - }, - AnnotationTool::Eraser => self.erase_at(self.annotation_point(position), cx), - AnnotationTool::None => {}, - } - cx.notify(); - } - - fn continue_annotation(&mut self, position: gpui::Point, cx: &mut Context) { - if self.annotation_tool != AnnotationTool::Marker || self.drawing_points.is_empty() { - return; - } - let point = self.annotation_point(position); - let should_append = self.drawing_points.last().is_none_or(|last| { - let dx = point.x - last.x; - let dy = point.y - last.y; - dx * dx + dy * dy >= 4.0 - }); - if should_append { - self.drawing_points.push(point); - cx.notify(); - } - } - - fn finish_annotation(&mut self, cx: &mut Context) { - let Some(sheet_id) = self.active_sheet else { - self.drawing_points.clear(); - return; - }; - if self.drawing_points.len() < 2 { - self.drawing_points.clear(); - return; - } - let points = simplify_stroke(&std::mem::take(&mut self.drawing_points), 1.5); - let bbox = stroke_bbox(&points); - let stroke = AnnotationStroke { - id: uuid::Uuid::new_v4(), - sheet_id, - originator: self.local_annotation_originator.clone(), - points, - style: StrokeStyle { - color_rgba: 0xfff5_9e0b, - width: 4.0, - opacity: 0.55, - }, - bbox, - }; - if self.document.add_annotation(sheet_id, stroke).is_ok() { - self.changed(self.active_cell, cx); - } - } - - fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { - let Some(sheet) = self - .active_sheet - .and_then(|sheet_id| self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)) - else { - return; - }; - let radius = 10.0; - let touched = sheet - .annotations - .iter() - .find(|stroke| { - stroke.originator == self.local_annotation_originator - && point.x >= stroke.bbox.min.x - radius - && point.x <= stroke.bbox.max.x + radius - && point.y >= stroke.bbox.min.y - radius - && point.y <= stroke.bbox.max.y + radius - && stroke.points.windows(2).any(|segment| segment_distance(point, segment[0], segment[1]) <= radius) - }) - .map(|stroke| stroke.id); - if let Some(stroke_id) = touched - && self - .document - .delete_annotation(sheet.id, stroke_id, &self.local_annotation_originator) - .is_ok_and(|removed| removed) - { - self.changed(self.active_cell, cx); - } - } - pub fn document_path(&self) -> Option<&PathBuf> { self.path.as_ref() } @@ -335,6 +214,10 @@ impl FlowEditor { .find(|sheet| sheet.cells.iter().any(|cell| cell.id == cell_id)) .map(|sheet| sheet.id); if let Some(sheet_id) = sheet_id { + if self.document.ensure_cell_editable_projection(sheet_id, cell_id).is_ok_and(|changed| changed) { + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + } self.ensure_cell_editor(cell_id, cx); self.active_sheet = Some(sheet_id); self.active_cell = Some(cell_id); @@ -366,11 +249,42 @@ impl FlowEditor { } } + pub fn add_first_response_to(&mut self, cell: CellId, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if let Ok(id) = self.document.add_first_response(sheet, cell) { + self.collapsed_outline_items.insert(id); + self.ensure_cell_editor(id, cx); + self.changed(Some(id), cx); + } + } + + pub fn active_cell_is_struck(&self) -> bool { + self + .active_sheet + .zip(self.active_cell) + .and_then(|(sheet, cell)| self.document.projection().sheets.iter().find(|candidate| candidate.id == sheet)?.cells.iter().find(|candidate| candidate.id == cell)) + .and_then(|cell| cell.document().ok()) + .is_some_and(|document| document.paragraphs.iter().flat_map(|paragraph| ¶graph.runs).all(|run| run.styles.strikethrough)) + } + pub fn add_first_argument(&mut self, cx: &mut Context) { let Some(sheet) = self.active_sheet else { return; }; - if let Ok(id) = self.document.add_plain_cell(sheet, 0, None, None) { + if let Ok(id) = self.document.add_orphan_at_column_top(sheet, 0) { + self.collapsed_outline_items.insert(id); + self.ensure_cell_editor(id, cx); + self.changed(Some(id), cx); + } + } + + pub fn add_orphan_at_column_top(&mut self, column_index: usize, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if let Ok(id) = self.document.add_orphan_at_column_top(sheet, column_index) { self.collapsed_outline_items.insert(id); self.ensure_cell_editor(id, cx); self.changed(Some(id), cx); @@ -435,47 +349,6 @@ impl FlowEditor { } } - fn update_cell_drop(&mut self, target: CellId, position: RelativePosition, cx: &mut Context) { - self.pending_cell_drop = Some((target, position)); - cx.notify(); - } - - fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { - let Some((target, position)) = self.pending_cell_drop.take() else { - return; - }; - if dragged == target { - return; - } - let Some(sheet_id) = self.active_sheet else { - return; - }; - let Some(sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { - return; - }; - let Some(target_index) = sheet.cells.iter().position(|cell| cell.id == target) else { - return; - }; - let target_cell = &sheet.cells[target_index]; - let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { - return; - }; - let Some(column_index) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { - return; - }; - let insertion_index = match position { - RelativePosition::Before => target_index, - RelativePosition::After => target_index + 1, - }; - if self - .document - .move_cell(sheet_id, dragged, column_index, insertion_index, target_cell.parent_id) - .is_ok() - { - self.changed(Some(dragged), cx); - } - } - fn set_cell_bounds(&mut self, cell_id: CellId, bounds: Bounds, cx: &mut Context) { if self.cell_bounds.get(&cell_id) != Some(&bounds) { self.cell_bounds.insert(cell_id, bounds); @@ -509,6 +382,21 @@ impl FlowEditor { }; } + fn cell_text_color(&self, cell_id: CellId, cx: &App) -> gpui::Hsla { + self + .document + .projection() + .sheets + .iter() + .find_map(|sheet| { + let cell = sheet.cells.iter().find(|cell| cell.id == cell_id)?; + let definition = self.document.projection().format.sheet_type(sheet.sheet_type_id)?; + let column = definition.columns.iter().find(|column| column.id == cell.column_id)?; + Some(flow_side_palette(column.side, cx).base) + }) + .unwrap_or(cx.theme().foreground) + } + fn begin_space_pan(&mut self, position: gpui::Point, cx: &mut Context) { if self.space_pan_armed { self.pan_drag_anchor = Some((position, self.board_scroll.offset())); @@ -563,6 +451,8 @@ impl FlowEditor { let fallback = self.document.deletion_fallback(sheet, cell); if self.document.delete_cell(sheet, cell).is_ok() { self.cell_editors.remove(&cell); + self.cell_editor_themes.remove(&cell); + self.cell_editor_subscriptions.remove(&cell); if let Some(fallback) = fallback { self.ensure_cell_editor(fallback, cx); self.changed(Some(fallback), cx); @@ -620,78 +510,6 @@ impl FlowEditor { cx.notify(); } - fn ensure_cell_editor(&mut self, cell_id: CellId, cx: &mut Context) { - if self.cell_editors.contains_key(&cell_id) { - return; - } - let Some((sheet_id, document, uses_summary_projection)) = self.document.projection().sheets.iter().find_map(|sheet| { - sheet - .cells - .iter() - .find(|cell| cell.id == cell_id) - .and_then(|cell| cell.document().ok().map(|document| (document, cell.uses_summary_projection().unwrap_or(false)))) - .map(|(document, uses_summary_projection)| (sheet.id, document, uses_summary_projection)) - }) else { - return; - }; - let editor = cx.new(|cx| { - let mut editor = RichTextEditor::new_with_path(document, None, cx); - editor.set_invisibility_mode(uses_summary_projection, cx); - editor.update_config(|config| config.allow_paragraph_breaks = false, cx); - editor - }); - let subscription = cx.observe(&editor, move |flow, editor, cx| { - let document = editor.read(cx).document().clone(); - let Ok(bytes) = flowstate_document::db8_bytes(&document) else { - return; - }; - let unchanged = flow - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) - .is_some_and(|cell| cell.document_bytes == bytes); - if unchanged { - return; - } - if flow.document.replace_cell_document(sheet_id, cell_id, &document).is_ok() { - flow.dirty = true; - cx.emit(FlowEditorEvent::Changed); - cx.notify(); - } - }); - self.cell_editors.insert(cell_id, editor); - self.cell_editor_subscriptions.push(subscription); - } - - fn sync_cell_editors(&mut self, cx: &mut Context) { - let cells: std::collections::HashMap<_, _> = self - .document - .projection() - .sheets - .iter() - .flat_map(|sheet| sheet.cells.iter().filter_map(|cell| cell.document().ok().map(|document| (cell.id, document)))) - .collect(); - self.cell_editors.retain(|id, _| cells.contains_key(id)); - for (cell_id, editor) in &self.cell_editors { - if let Some(document) = cells.get(cell_id) { - let current = flowstate_document::db8_bytes(editor.read(cx).document()).ok(); - let desired = flowstate_document::db8_bytes(document).ok(); - if current != desired { - let document = document.clone(); - editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(document, cx)); - } - } - } - if let Some(active) = self.active_cell - && cells.contains_key(&active) - { - self.ensure_cell_editor(active, cx); - } - } - pub fn save(&mut self, cx: &mut Context) -> Task> { let Some(path) = self.path.clone() else { return cx @@ -755,145 +573,243 @@ impl FlowEditor { Vec::new() }; let draft = self.drawing_points.clone(); - let annotation_active = self.annotation_tool != AnnotationTool::None && !self.space_pan_armed; - let connector_bounds = self.cell_bounds.clone(); - let connector_edges: Vec<_> = sheet + let client_document_theme = load_document_theme(); + let cell_layout = sheet_cell_layout(sheet, &self.cell_bounds); + let board_width = px(32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0); + let weak_editor = cx.entity().downgrade(); + let weak_connector_editor = weak_editor.clone(); + let connector_families = sheet .cells .iter() - .filter_map(|cell| { - let parent = cell.parent_id?; - let side = definition.columns.iter().find(|column| column.id == cell.column_id)?.side; - Some((parent, cell.id, side)) + .filter(|cell| sheet.cells.iter().any(|child| child.parent_id == Some(cell.id))) + .map(|parent| { + ( + parent.id, + sheet.cells.iter().filter(|child| child.parent_id == Some(parent.id)).map(|child| child.id).collect::>(), + ) }) - .collect(); - let weak_editor = cx.entity().downgrade(); + .collect::>(); div() .id("flow-columns") .relative() + .min_w_full() + .min_h_full() + .w(board_width) .flex() - .gap(px(12.0)) + .gap(px(16.0)) .p(px(16.0)) - .children(definition.columns.iter().map(|column| { + .children(definition.columns.iter().enumerate().map(|(column_index, column)| { let side_palette = flow_side_palette(column.side, cx); let side_color = side_palette.base; + let can_receive_child = column_index + 1 < definition.columns.len(); + let add_editor = cx.entity().clone(); div() .w(px(280.0)) .flex_none() .flex_col() - .gap(px(8.0)) + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { + editor.update_column_drop(column_index, event.event.position.y, cx); + })) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))) .child( div() + .flex() + .items_center() + .justify_between() .font_weight(gpui::FontWeight::BOLD) .text_color(side_color) .border_b_2() .border_color(side_color) - .child(column.label.clone()), + .child(column.label.clone()) + .child( + div() + .flex() + .gap_1() + .child( + Button::new(("flow-send-to-document", column_index)) + .xsmall() + .ghost() + .tooltip("Send to document") + .child(Icon::default().path("icons/file-input.svg").xsmall()) + .on_click(|_, _, _| {}), + ) + .child( + Button::new(("flow-add-column-orphan", column_index)) + .xsmall() + .ghost() + .tooltip("Add orphan at top") + .icon(IconName::Plus) + .on_click(move |_, window, cx| { + add_editor.update(cx, |editor, cx| { + editor.set_annotation_tool(AnnotationTool::None, cx); + editor.add_orphan_at_column_top(column_index, cx); + editor.focus_active_cell(window, cx); + }); + }), + ), + ), ) - .children(sheet.cells.iter().filter(|cell| cell.column_id == column.id).map(|cell| { + .child(div().h(px(12.0)).flex_none()) + .children({ + let mut previous_bottom = 0.0; + sheet.cells.iter().filter(|cell| cell.column_id == column.id).map(|cell| { let id = cell.id; + let layout = cell_layout.get(&id).copied().unwrap_or_default(); + let spacer_height = px((layout.top - previous_bottom).max(0.0)); + previous_bottom = layout.top + layout.height; let label: SharedString = cell .summary_text() .unwrap_or_else(|_| "Invalid rich text".into()) .into(); - let estimated_lines = label - .lines() - .map(|line| line.chars().count().div_ceil(34).max(1)) - .sum::() - .max(1); - let cell_height = px(32.0 + 22.0 * estimated_lines as f32); + let mut uses_summary_projection = cell.uses_summary_projection().unwrap_or(false); + let mut rendered_document = cell.document().ok(); + if let Some(document) = rendered_document.as_mut() { + if !uses_summary_projection + && let Some(paragraph) = std::sync::Arc::make_mut(&mut document.paragraphs).first_mut() + { + paragraph.style = flowstate_document::PARAGRAPH_TAG; + document.blocks = std::sync::Arc::new(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs)); + uses_summary_projection = true; + } + apply_flow_cell_theme(document, &client_document_theme, side_color, cx.theme().background); + } let cell_editor = self.cell_editors.get(&id).cloned(); - let is_drop_target = self.pending_cell_drop.is_some_and(|(target, _)| target == id); + let reply_editor = cx.entity().clone(); + let is_drop_target = self.pending_cell_drop.is_some_and(|destination| { + matches!(destination, CellDropDestination::Relative(target, _) | CellDropDestination::ChildOf(target) if target == id) + }); div() - .id(("flow-cell", id.as_u128() as u64)) - .relative() - .h(cell_height) - .p(px(10.0)) - .rounded(px(6.0)) - .border_1() - .border_color(if active == Some(id) { - side_palette.active - } else { - side_color.opacity(0.56) - }) - .bg(if active == Some(id) { - side_palette.active.opacity(0.14) - } else { - cx.theme().background + .w_full() + .flex_col() + .when(spacer_height > px(0.0), |this| this.child(div().h(spacer_height).flex_none())) + .on_children_prepainted({ + let weak_editor = weak_editor.clone(); + move |bounds, _, cx| { + if let Some(card_bounds) = bounds.last().copied() { + let _ = weak_editor.update(cx, |editor, cx| editor.set_cell_bounds(id, card_bounds, cx)); + } + } }) - .hover(|style| style.bg(side_palette.hover.opacity(0.12))) - .cursor_pointer() - .on_click(cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) - .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { - let position = if event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0 { - RelativePosition::Before - } else { - RelativePosition::After - }; - editor.update_cell_drop(id, position, cx); - })) .child( - div() - .absolute() - .inset_0() - .invisible() - .group_drag_over::("", |this| this.visible().border_2().border_color(side_palette.hover)) - .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))), - ) - .when(is_drop_target, |this| this.border_2().border_color(side_palette.active)) - .child( - canvas( - { - let weak_editor = weak_editor.clone(); - move |bounds, _, cx| { - let _ = weak_editor.update(cx, |editor, cx| editor.set_cell_bounds(id, bounds, cx)); - } - }, - |_, _, _, _| {}, + div() + .id(("flow-cell", id.as_u128() as u64)) + .relative() + .w_full() + .min_h(px(54.0)) + .p(px(10.0)) + .rounded(px(6.0)) + .border_1() + .border_color(if active == Some(id) { + side_palette.active + } else { + side_color.opacity(0.56) + }) + .bg(if active == Some(id) { + side_palette.active.opacity(0.14) + } else { + cx.theme().background + }) + .hover(|style| style.bg(side_palette.hover.opacity(0.12))) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, cx.listener(|editor, _, _, cx| { + if editor.annotation_tool != AnnotationTool::None { + editor.set_annotation_tool(AnnotationTool::None, cx); + cx.stop_propagation(); + } + })) + .on_click(cx.listener(move |editor, _, window, cx| { + editor.activate_cell(id, cx); + if let Some(cell_editor) = editor.cell_editors.get(&id) { + cell_editor.read(cx).focus_handle(cx).focus(window); + } + })) + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { + let right_zone = event.event.position.x >= event.bounds.left() + event.bounds.size.width * 0.72; + let destination = if right_zone && can_receive_child { + CellDropDestination::ChildOf(id) + } else if event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0 { + CellDropDestination::Relative(id, RelativePosition::Before) + } else { + CellDropDestination::Relative(id, RelativePosition::After) + }; + editor.update_cell_drop(destination, cx); + cx.stop_propagation(); + })) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| { + editor.finish_cell_drop(drag.cell_id, cx); + cx.stop_propagation(); + })) + .when(is_drop_target, |this| this.border_2().border_color(side_palette.active)) + .child( + div() + .id(("flow-cell-drag-handle", id.as_u128() as u64)) + .absolute() + .top(px(2.0)) + .right(px(4.0)) + .cursor_move() + .text_color(cx.theme().muted_foreground) + .child("⠿") + .on_mouse_down(MouseButton::Left, cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) + .on_drag(FlowCellDrag { cell_id: id }, |_, _, _, cx| cx.new(|_| FlowCellDragPreview)), ) - .absolute() - .size_full(), - ) - .child( - div() - .id(("flow-cell-drag-handle", id.as_u128() as u64)) - .absolute() - .top(px(2.0)) - .right(px(4.0)) - .cursor_move() - .text_color(cx.theme().muted_foreground) - .child("⠿") - .on_mouse_down(MouseButton::Left, cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) - .on_drag(FlowCellDrag { cell_id: id }, |_, _, _, cx| cx.new(|_| FlowCellDragPreview)), - ) - .child(if active == Some(id) { - cell_editor.map_or_else(|| div().child(label).into_any_element(), |editor| editor.into_any_element()) - } else { - div().child(label).into_any_element() - }) - })) + .when(can_receive_child, |this| { + this.child( + Button::new(("flow-cell-reply", id.as_u128() as u64)) + .absolute() + .top(px(24.0)) + .right(px(2.0)) + .xsmall() + .ghost() + .tooltip("Add first reply") + .child(Icon::default().path("icons/message-square-reply.svg").xsmall()) + .on_click(move |_, window, cx| reply_editor.update(cx, |editor, cx| { + editor.set_annotation_tool(AnnotationTool::None, cx); + editor.add_first_response_to(id, cx); + editor.focus_active_cell(window, cx); + })), + ) + }) + .child(if active == Some(id) { + cell_editor.map_or_else(|| div().child(label).into_any_element(), |editor| editor.into_any_element()) + } else { + rendered_document.map_or_else( + || div().child(label).into_any_element(), + |document| RichTextDocumentElement::new(document).with_invisibility_mode(uses_summary_projection).into_any_element(), + ) + }), + ) + }).collect::>()}) })) .child( canvas( |_, _, _| {}, - move |bounds, _, window, cx| { - for (parent_id, child_id, side) in &connector_edges { + move |_canvas_bounds, _, window, cx| { + let Some(editor) = weak_connector_editor.upgrade() else { + return; + }; + let connector_bounds = &editor.read(cx).cell_bounds; + for (parent_id, child_ids) in &connector_families { let Some(parent) = connector_bounds.get(parent_id) else { continue; }; - let Some(child) = connector_bounds.get(child_id) else { + let children = child_ids.iter().filter_map(|id| connector_bounds.get(id)).collect::>(); + let Some(first_child) = children.first() else { continue; }; - let color = flow_side_palette(*side, cx).base; - let start = point(parent.right() - bounds.origin.x, parent.center().y - bounds.origin.y); - let end = point(child.left() - bounds.origin.x, child.center().y - bounds.origin.y); - let midpoint = start.x + (end.x - start.x) / 2.0; + let start = point(parent.right(), parent.center().y); + let midpoint = start.x + (first_child.left() - start.x) / 2.0; + let lowest_y = children.iter().map(|child| child.center().y).max().unwrap_or(start.y); let mut path = PathBuilder::stroke(px(1.5)); path.move_to(start); path.line_to(point(midpoint, start.y)); - path.line_to(point(midpoint, end.y)); - path.line_to(end); + path.line_to(point(midpoint, lowest_y)); + for child in children { + let end = point(child.left(), child.center().y); + path.move_to(point(midpoint, end.y)); + path.line_to(end); + } if let Ok(path) = path.build() { - window.paint_path(path, color.opacity(0.68)); + window.paint_path(path, cx.theme().secondary); } } }, @@ -911,88 +827,28 @@ impl FlowEditor { |_, _, _| {}, move |bounds, _, window, _| { for stroke in &strokes { + let color = gpui::Hsla::from(rgba(stroke.style.color_rgba)); paint_stroke( bounds.origin, &stroke.points, px(stroke.style.width), - gpui::Hsla::from(rgba(stroke.style.color_rgba)).opacity(stroke.style.opacity), + color.opacity(color.a * stroke.style.opacity), window, ); } if !draft.is_empty() { - paint_stroke(bounds.origin, &draft, px(4.0), gpui::Hsla::from(rgba(0xfff5_9e0b)).opacity(0.55), window); + paint_stroke(bounds.origin, &draft, px(4.0), gpui::Hsla::from(rgba(0xf59e_0bff)).opacity(0.55), window); } }, ) .absolute() .size_full(), - ) - .when(annotation_active, |this| { - this - .on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| { - editor.begin_annotation(event.position, cx); - cx.stop_propagation(); - })) - .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, cx| editor.continue_annotation(event.position, cx))) - .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx))) - }), + ), ) .into_any_element() } } -fn stroke_bbox(points: &[BoardPoint]) -> BoardRect { - let mut min = points[0]; - let mut max = points[0]; - for point in &points[1..] { - min.x = min.x.min(point.x); - min.y = min.y.min(point.y); - max.x = max.x.max(point.x); - max.y = max.y.max(point.y); - } - BoardRect { min, max } -} - -fn simplify_stroke(points: &[BoardPoint], minimum_distance: f32) -> Vec { - let Some(first) = points.first().copied() else { - return Vec::new(); - }; - let mut simplified = Vec::with_capacity(points.len()); - simplified.push(first); - for window in points.windows(3) { - let smoothed = BoardPoint { - x: (window[0].x + window[1].x + window[2].x) / 3.0, - y: (window[0].y + window[1].y + window[2].y) / 3.0, - }; - if simplified - .last() - .is_none_or(|previous| (smoothed.x - previous.x).hypot(smoothed.y - previous.y) >= minimum_distance) - { - simplified.push(smoothed); - } - } - if let Some(last) = points.last().copied() - && simplified.last() != Some(&last) - { - simplified.push(last); - } - simplified -} - -fn segment_distance(point: BoardPoint, start: BoardPoint, end: BoardPoint) -> f32 { - let dx = end.x - start.x; - let dy = end.y - start.y; - let length_squared = dx * dx + dy * dy; - let t = if length_squared == 0.0 { - 0.0 - } else { - (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0) - }; - let nearest_x = start.x + t * dx; - let nearest_y = start.y + t * dy; - (point.x - nearest_x).hypot(point.y - nearest_y) -} - fn paint_stroke(origin: gpui::Point, points: &[BoardPoint], width: gpui::Pixels, color: gpui::Hsla, window: &mut Window) { let Some(first) = points.first() else { return; @@ -1017,12 +873,13 @@ impl Focusable for FlowEditor { impl Render for FlowEditor { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.refresh_active_cell_theme(cx); + let grid_scroll = self.board_scroll.clone(); + let board_zoom = self.board_zoom; div() .id("flow-editor") .relative() .size_full() - .overflow_scroll() - .track_scroll(&self.board_scroll) .track_focus(&self.focus_handle) .on_key_down(cx.listener(|editor, event: &KeyDownEvent, _, cx| { if event.keystroke.key == "space" { @@ -1048,33 +905,83 @@ impl Render for FlowEditor { .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_space_pan(cx))) .child( canvas( - { - let weak = cx.entity().downgrade(); - move |bounds, _, cx| { - let _ = weak.update(cx, |editor, _| editor.set_viewport_origin(bounds.origin)); + |_, _, _| {}, + move |bounds, _, window, cx| { + let spacing = px(24.0 * board_zoom); + let offset = grid_scroll.offset(); + let mut x = offset.x % spacing; + let color = cx.theme().border.opacity(0.56); + while x < bounds.size.width { + let mut y = offset.y % spacing; + while y < bounds.size.height { + window.paint_quad(gpui::fill( + gpui::Bounds::new(bounds.origin + point(x, y), gpui::size(px(1.5), px(1.5))), + color, + )); + y += spacing; + } + x += spacing; } }, - |_, _, _, _| {}, ) .absolute() - .size_full(), + .inset_0(), ) - .child(match self.active_sheet { - Some(sheet) => self.render_sheet(sheet, cx).into_any_element(), - None => div() + .child( + div() + .id("flow-board-scroll") + .relative() .size_full() - .flex() - .items_center() - .justify_center() - .text_color(cx.theme().muted_foreground) - .child("Create a sheet to begin flowing") - .into_any_element(), - }) + .overflow_scroll() + .track_scroll(&self.board_scroll) + .on_scroll_wheel(cx.listener(|editor, event: &ScrollWheelEvent, window, cx| { + if event.modifiers.shift { + let delta = event.delta.pixel_delta(window.line_height()); + let mut offset = editor.board_scroll.offset(); + offset.x += delta.y + delta.x; + editor.board_scroll.set_offset(offset); + cx.stop_propagation(); + cx.notify(); + } + })) + .when(self.annotation_tool != AnnotationTool::None && !self.space_pan_armed, |this| { + this + .on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| editor.begin_annotation(event.position, cx))) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, cx| editor.continue_annotation(event.position, cx))) + .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx))) + .on_mouse_up_out(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx))) + }) + .child( + canvas( + { + let weak = cx.entity().downgrade(); + move |bounds, _, cx| { + let _ = weak.update(cx, |editor, _| editor.set_viewport_origin(bounds.origin)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(match self.active_sheet { + Some(sheet) => self.render_sheet(sheet, cx).into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child("Create a sheet to begin flowing") + .into_any_element(), + }), + ) .child( div() .absolute() .inset_0() - .child(Scrollbar::horizontal(&self.board_scroll).scrollbar_show(ScrollbarShow::Scrolling)), + .child(Scrollbar::vertical(&self.board_scroll).scrollbar_show(ScrollbarShow::Always)) + .child(Scrollbar::horizontal(&self.board_scroll).scrollbar_show(ScrollbarShow::Always)), ) } } diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs new file mode 100644 index 00000000..a4b01f7d --- /dev/null +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -0,0 +1,196 @@ +use flowstate_flow::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, StrokeStyle}; +use gpui::{Context, Pixels, Point}; +use gpui_component::PixelsExt as _; + +use super::{AnnotationTool, FlowEditor}; + +impl FlowEditor { + pub fn set_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { + self.annotation_tool = tool; + cx.notify(); + } + + pub fn toggle_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { + self.annotation_tool = if self.annotation_tool == tool { AnnotationTool::None } else { tool }; + cx.notify(); + } + + pub fn toggle_annotations_visible(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if !self.hidden_annotation_sheets.remove(&sheet) { + self.hidden_annotation_sheets.insert(sheet); + } + cx.notify(); + } + + pub fn set_originator_annotations_hidden(&mut self, originator: AnnotationOriginator, hidden: bool, cx: &mut Context) { + if hidden { + self.hidden_annotation_originators.insert(originator); + } else { + self.hidden_annotation_originators.remove(&originator); + } + cx.notify(); + } + + pub fn set_local_annotation_originator(&mut self, originator: AnnotationOriginator) { + self.local_annotation_originator = originator; + } + + pub fn clear_annotations(&mut self, cx: &mut Context) { + if self.document.clear_all_annotations(&self.local_annotation_originator).is_ok() { + self.changed(self.active_cell, cx); + } + } + + fn annotation_point(&self, position: Point) -> BoardPoint { + let offset = self.board_scroll.offset(); + BoardPoint { + x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom - offset.x.as_f32(), + y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom - offset.y.as_f32(), + } + } + + pub(super) fn begin_annotation(&mut self, position: Point, cx: &mut Context) { + match self.annotation_tool { + AnnotationTool::Marker => { + self.drawing_points.clear(); + self.drawing_points.push(self.annotation_point(position)); + }, + AnnotationTool::Eraser => self.erase_at(self.annotation_point(position), cx), + AnnotationTool::None => {}, + } + cx.notify(); + } + + pub(super) fn continue_annotation(&mut self, position: Point, cx: &mut Context) { + if self.annotation_tool == AnnotationTool::Eraser { + self.erase_at(self.annotation_point(position), cx); + return; + } + if self.annotation_tool != AnnotationTool::Marker || self.drawing_points.is_empty() { + return; + } + let point = self.annotation_point(position); + let should_append = self.drawing_points.last().is_none_or(|last| { + let dx = point.x - last.x; + let dy = point.y - last.y; + dx * dx + dy * dy >= 4.0 + }); + if should_append { + self.drawing_points.push(point); + cx.notify(); + } + } + + pub(super) fn finish_annotation(&mut self, cx: &mut Context) { + let Some(sheet_id) = self.active_sheet else { + self.drawing_points.clear(); + return; + }; + if self.drawing_points.len() < 2 { + self.drawing_points.clear(); + return; + } + let points = simplify_stroke(&std::mem::take(&mut self.drawing_points), 1.5); + let bbox = stroke_bbox(&points); + let stroke = AnnotationStroke { + id: uuid::Uuid::new_v4(), + sheet_id, + originator: self.local_annotation_originator.clone(), + points, + style: StrokeStyle { + color_rgba: 0xf59e_0bff, + width: 4.0, + opacity: 0.55, + }, + bbox, + }; + if self.document.add_annotation(sheet_id, stroke).is_ok() { + self.changed(self.active_cell, cx); + } + } + + fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { + let Some(sheet) = self + .active_sheet + .and_then(|sheet_id| self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)) + else { + return; + }; + let radius = 10.0; + let touched = sheet + .annotations + .iter() + .find(|stroke| { + stroke.originator == self.local_annotation_originator + && point.x >= stroke.bbox.min.x - radius + && point.x <= stroke.bbox.max.x + radius + && point.y >= stroke.bbox.min.y - radius + && point.y <= stroke.bbox.max.y + radius + && stroke.points.windows(2).any(|segment| segment_distance(point, segment[0], segment[1]) <= radius) + }) + .map(|stroke| stroke.id); + if let Some(stroke_id) = touched + && self + .document + .delete_annotation(sheet.id, stroke_id, &self.local_annotation_originator) + .is_ok_and(|removed| removed) + { + self.changed(self.active_cell, cx); + } + } +} + +fn stroke_bbox(points: &[BoardPoint]) -> BoardRect { + let mut min = points[0]; + let mut max = points[0]; + for point in &points[1..] { + min.x = min.x.min(point.x); + min.y = min.y.min(point.y); + max.x = max.x.max(point.x); + max.y = max.y.max(point.y); + } + BoardRect { min, max } +} + +fn simplify_stroke(points: &[BoardPoint], minimum_distance: f32) -> Vec { + let Some(first) = points.first().copied() else { + return Vec::new(); + }; + let mut simplified = Vec::with_capacity(points.len()); + simplified.push(first); + for window in points.windows(3) { + let smoothed = BoardPoint { + x: (window[0].x + window[1].x + window[2].x) / 3.0, + y: (window[0].y + window[1].y + window[2].y) / 3.0, + }; + if simplified + .last() + .is_none_or(|previous| (smoothed.x - previous.x).hypot(smoothed.y - previous.y) >= minimum_distance) + { + simplified.push(smoothed); + } + } + if let Some(last) = points.last().copied() + && simplified.last() != Some(&last) + { + simplified.push(last); + } + simplified +} + +fn segment_distance(point: BoardPoint, start: BoardPoint, end: BoardPoint) -> f32 { + let dx = end.x - start.x; + let dy = end.y - start.y; + let length_squared = dx * dx + dy * dy; + let t = if length_squared == 0.0 { + 0.0 + } else { + (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0) + }; + let nearest_x = start.x + t * dx; + let nearest_y = start.y + t * dy; + (point.x - nearest_x).hypot(point.y - nearest_y) +} diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs new file mode 100644 index 00000000..635715f6 --- /dev/null +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -0,0 +1,138 @@ +use flowstate_flow::CellId; +use gpui::{AppContext as _, Context}; +use gpui_component::ActiveTheme as _; + +use crate::{app_settings::load_document_theme, flow::cell_theme::apply_flow_cell_theme, rich_text_element::RichTextEditor}; + +use super::{FlowEditor, FlowEditorEvent}; + +impl FlowEditor { + pub(super) fn ensure_cell_editor(&mut self, cell_id: CellId, cx: &mut Context) { + if self.cell_editors.contains_key(&cell_id) { + return; + } + let Some((sheet_id, mut document, uses_summary_projection)) = self.document.projection().sheets.iter().find_map(|sheet| { + sheet + .cells + .iter() + .find(|cell| cell.id == cell_id) + .and_then(|cell| cell.document().ok().map(|document| (document, cell.uses_summary_projection().unwrap_or(false)))) + .map(|(document, uses_summary_projection)| (sheet.id, document, uses_summary_projection)) + }) else { + return; + }; + let text_color = self.cell_text_color(cell_id, cx); + apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background); + let editor = cx.new(|cx| { + let mut editor = RichTextEditor::new_with_path(document, None, cx); + editor.set_invisibility_mode(uses_summary_projection, cx); + editor.update_config( + |config| { + config.allow_paragraph_breaks = false; + config.flow_cell_surface = true; + config.show_section_collapse_controls = false; + config.caret_color = Some(text_color); + }, + cx, + ); + editor + }); + let subscription = cx.observe(&editor, move |flow, editor, cx| { + let mut document = editor.read(cx).document().clone(); + let source_theme = flow + .document + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id) + .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) + .and_then(|cell| cell.document().ok()) + .map(|document| document.theme); + if let Some(source_theme) = source_theme { + document.theme = source_theme; + } + let Ok(bytes) = flowstate_document::db8_bytes(&document) else { + return; + }; + let unchanged = flow + .document + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id) + .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) + .is_some_and(|cell| cell.document_bytes == bytes); + if unchanged { + return; + } + if flow.document.replace_cell_document(sheet_id, cell_id, &document).is_ok() { + flow.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + }); + self.cell_editors.insert(cell_id, editor); + self.cell_editor_themes.insert(cell_id, (text_color, cx.theme().background)); + self.cell_editor_subscriptions.insert(cell_id, subscription); + } + + pub(super) fn sync_cell_editors(&mut self, cx: &mut Context) { + let client_theme = load_document_theme(); + let cells: std::collections::HashMap<_, _> = self + .document + .projection() + .sheets + .iter() + .flat_map(|sheet| sheet.cells.iter().filter_map(|cell| cell.document().ok().map(|document| (cell.id, document)))) + .collect(); + self.cell_editors.retain(|id, _| cells.contains_key(id)); + self.cell_editor_themes.retain(|id, _| cells.contains_key(id)); + self.cell_editor_subscriptions.retain(|id, _| cells.contains_key(id)); + for (cell_id, editor) in &self.cell_editors { + if let Some(document) = cells.get(cell_id) { + let current = flowstate_document::db8_bytes(editor.read(cx).document()).ok(); + let mut themed_document = document.clone(); + let text_color = self.cell_text_color(*cell_id, cx); + apply_flow_cell_theme(&mut themed_document, &client_theme, text_color, cx.theme().background); + let desired = flowstate_document::db8_bytes(&themed_document).ok(); + if current != desired { + editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(themed_document, cx)); + self.cell_editor_themes.insert(*cell_id, (text_color, cx.theme().background)); + } + } + } + if let Some(active) = self.active_cell + && cells.contains_key(&active) + { + self.ensure_cell_editor(active, cx); + } + } + + pub(super) fn refresh_active_cell_theme(&mut self, cx: &mut Context) { + let Some(cell_id) = self.active_cell else { + return; + }; + let Some(editor) = self.cell_editors.get(&cell_id) else { + return; + }; + let text_color = self.cell_text_color(cell_id, cx); + let signature = (text_color, cx.theme().background); + if self.cell_editor_themes.get(&cell_id) == Some(&signature) { + return; + } + let Some(mut document) = self + .document + .projection() + .sheets + .iter() + .flat_map(|sheet| &sheet.cells) + .find(|cell| cell.id == cell_id) + .and_then(|cell| cell.document().ok()) + else { + return; + }; + apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background); + editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(document, cx)); + self.cell_editor_themes.insert(cell_id, signature); + } +} diff --git a/crates/flowstate/src/flow/editor/drag_drop.rs b/crates/flowstate/src/flow/editor/drag_drop.rs new file mode 100644 index 00000000..2f7cc8f5 --- /dev/null +++ b/crates/flowstate/src/flow/editor/drag_drop.rs @@ -0,0 +1,140 @@ +use flowstate_flow::{CellId, RelativePosition}; +use gpui::{Context, IntoElement, Render, Window, div, prelude::*}; +use gpui_component::ActiveTheme as _; + +use super::FlowEditor; + +#[derive(Clone)] +pub(super) struct FlowCellDrag { + pub(super) cell_id: CellId, +} + +pub(super) struct FlowCellDragPreview; + +#[derive(Clone, Copy)] +pub(super) enum CellDropDestination { + Relative(CellId, RelativePosition), + ChildOf(CellId), + OrphanInColumn { column_index: usize, insertion_index: usize }, +} + +impl Render for FlowCellDragPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .child("Move argument") + } +} + +impl FlowEditor { + pub(super) fn update_cell_drop(&mut self, destination: CellDropDestination, cx: &mut Context) { + self.pending_cell_drop = Some(destination); + cx.notify(); + } + + pub(super) fn update_column_drop(&mut self, column_index: usize, y: gpui::Pixels, cx: &mut Context) { + let Some(sheet_id) = self.active_sheet else { + return; + }; + let Some(sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { + return; + }; + let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { + return; + }; + let Some(column) = definition.columns.get(column_index) else { + return; + }; + let column_cells: Vec<_> = sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| cell.column_id == column.id) + .collect(); + let below_position = column_cells + .iter() + .position(|(_, cell)| self.cell_bounds.get(&cell.id).is_some_and(|bounds| y < bounds.center().y)); + if let Some(below_position) = below_position + && let Some((_, above)) = below_position.checked_sub(1).and_then(|position| column_cells.get(position)) + && let Some((_, below)) = column_cells.get(below_position) + && above.parent_id == below.parent_id + { + self.update_cell_drop(CellDropDestination::Relative(below.id, RelativePosition::Before), cx); + return; + } + let insertion_index = below_position.map_or(sheet.cells.len(), |position| column_cells[position].0); + self.update_cell_drop( + CellDropDestination::OrphanInColumn { + column_index, + insertion_index, + }, + cx, + ); + } + + pub(super) fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { + let Some(destination) = self.pending_cell_drop.take() else { + return; + }; + let Some(sheet_id) = self.active_sheet else { + return; + }; + let Some(sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { + return; + }; + let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { + return; + }; + let (column_index, insertion_index, parent_id) = match destination { + CellDropDestination::Relative(target, position) => { + if dragged == target { + return; + } + let Some(target_index) = sheet.cells.iter().position(|cell| cell.id == target) else { + return; + }; + let target_cell = &sheet.cells[target_index]; + let Some(column_index) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { + return; + }; + let insertion_index = match position { + RelativePosition::Before => target_index, + RelativePosition::After => target_index + 1, + }; + (column_index, insertion_index, target_cell.parent_id) + }, + CellDropDestination::ChildOf(target) => { + if dragged == target { + return; + } + let Some(target_cell) = sheet.cells.iter().find(|cell| cell.id == target) else { + return; + }; + let Some(parent_column) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { + return; + }; + let Some(child_column) = parent_column.checked_add(1).filter(|index| *index < definition.columns.len()) else { + return; + }; + let insertion_index = self.document.child_append_index(sheet_id, target).unwrap_or(sheet.cells.len()); + (child_column, insertion_index, Some(target)) + }, + CellDropDestination::OrphanInColumn { + column_index, + insertion_index, + } => (column_index, insertion_index, None), + }; + if self + .document + .move_cell(sheet_id, dragged, column_index, insertion_index, parent_id) + .is_ok() + { + self.changed(Some(dragged), cx); + } + } +} diff --git a/crates/flowstate/src/flow/editor/layout.rs b/crates/flowstate/src/flow/editor/layout.rs new file mode 100644 index 00000000..ee639916 --- /dev/null +++ b/crates/flowstate/src/flow/editor/layout.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use flowstate_flow::{Cell, CellId, Sheet}; +use gpui::{Bounds, Pixels}; +use gpui_component::PixelsExt as _; + +pub(super) const CELL_GAP: f32 = 16.0; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct CellLayout { + pub top: f32, + pub height: f32, +} + +pub(super) fn sheet_cell_layout(sheet: &Sheet, bounds: &HashMap>) -> HashMap { + let mut layout = HashMap::with_capacity(sheet.cells.len()); + let mut top = 0.0; + for root in sheet.cells.iter().filter(|cell| cell.parent_id.is_none()) { + let family_height = layout_family(sheet, root.id, top, bounds, &mut layout); + top += family_height + CELL_GAP; + } + layout +} + +fn layout_family( + sheet: &Sheet, + cell_id: CellId, + top: f32, + bounds: &HashMap>, + layout: &mut HashMap, +) -> f32 { + let Some(cell) = sheet.cells.iter().find(|cell| cell.id == cell_id) else { + return 0.0; + }; + let cell_height = measured_cell_height(cell, bounds); + layout.insert(cell_id, CellLayout { top, height: cell_height }); + + let mut children_top = top; + let mut children_height = 0.0; + for child in sheet.cells.iter().filter(|child| child.parent_id == Some(cell_id)) { + let child_height = layout_family(sheet, child.id, children_top, bounds, layout); + children_top += child_height + CELL_GAP; + children_height += child_height + CELL_GAP; + } + if children_height > 0.0 { + children_height -= CELL_GAP; + } + cell_height.max(children_height) +} + +fn measured_cell_height(cell: &Cell, bounds: &HashMap>) -> f32 { + bounds.get(&cell.id).map_or_else(|| estimated_cell_height(cell), |bounds| bounds.size.height.as_f32()) +} + +fn estimated_cell_height(cell: &Cell) -> f32 { + let text = cell.summary_text().unwrap_or_default(); + let lines = text + .lines() + .map(|line| line.chars().count().div_ceil(34).max(1)) + .sum::() + .max(1); + 32.0 + 22.0 * lines as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use flowstate_flow::{Cell, ColumnId}; + + fn cell(parent_id: Option) -> Cell { + let mut cell = Cell::plain(ColumnId::new_v4()).unwrap(); + cell.parent_id = parent_id; + cell + } + + #[test] + fn unrelated_roots_occupy_disjoint_global_bands() { + let root = cell(None); + let child_one = cell(Some(root.id)); + let child_two = cell(Some(root.id)); + let orphan = cell(None); + let sheet = Sheet { + id: flowstate_flow::SheetId::new_v4(), + sheet_type_id: flowstate_flow::SheetTypeId::new_v4(), + name: String::new(), + cells: vec![root.clone(), child_one, child_two, orphan.clone()], + annotations: Vec::new(), + }; + + let layout = sheet_cell_layout(&sheet, &HashMap::new()); + + assert_eq!(layout[&root.id].top, 0.0); + assert_eq!(layout[&orphan.id].top, 2.0 * 54.0 + 2.0 * CELL_GAP); + } +} diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index 57d64d4f..64f3348f 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -1,3 +1,4 @@ +mod cell_theme; pub mod editor; pub mod panel; pub mod ribbon; diff --git a/crates/flowstate/src/flow/ribbon.rs b/crates/flowstate/src/flow/ribbon.rs index 4d9b688c..b2db2895 100644 --- a/crates/flowstate/src/flow/ribbon.rs +++ b/crates/flowstate/src/flow/ribbon.rs @@ -1,8 +1,8 @@ use flowstate_flow::RelativePosition; -use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Subscription, Window, div, prelude::*, px}; +use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, Render, Subscription, Window, div, prelude::*, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::{ActiveTheme as _, Disableable as _, Sizable as _}; +use gpui_component::{ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _}; use crate::flow::{AnnotationTool, FlowEditor}; @@ -51,8 +51,6 @@ impl Render for FlowRibbon { let editor = self.editor.clone(); let undo_editor = editor.clone(); let redo_editor = editor.clone(); - let create_aff_editor = editor.clone(); - let create_neg_editor = editor.clone(); let response_editor = editor.clone(); let first_argument_editor = editor.clone(); let previous_sheet_editor = editor.clone(); @@ -68,6 +66,17 @@ impl Render for FlowRibbon { let clear_editor = self.editor.clone(); let has_active_sheet = self.editor.read(cx).active_sheet().is_some(); let has_active_cell = self.editor.read(cx).active_cell().is_some(); + #[allow(clippy::needless_collect, reason = "Release the editor read guard before constructing buttons that clone the editor entity.")] + let sheet_types: Vec<_> = self + .editor + .read(cx) + .document() + .projection() + .format + .sheet_types + .iter() + .map(|sheet_type| sheet_type.name.clone()) + .collect(); let sheet_name_input = self.editor.read(cx).active_sheet().and_then(|sheet_id| { let sheet_name = self .editor @@ -103,6 +112,10 @@ impl Render for FlowRibbon { .gap(px(8.0)) .px(px(12.0)) .bg(cx.theme().secondary) + .on_mouse_down(MouseButton::Left, { + let editor = self.editor.clone(); + move |_: &MouseDownEvent, _, cx| editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::None, cx)) + }) .when_some(sheet_name_input, |this, input| this.child(div().w(px(180.0)).child(Input::new(&input).w_full()))) .child( Button::new("flow-undo") @@ -118,18 +131,13 @@ impl Render for FlowRibbon { .disabled(!self.editor.read(cx).can_redo()) .on_click(move |_, _, cx| redo_editor.update(cx, |editor, cx| editor.redo(cx))), ) - .child( - Button::new("flow-create-aff-sheet") - .label("New affirmative") + .children(sheet_types.into_iter().enumerate().map(|(index, name)| { + let editor = self.editor.clone(); + Button::new(("flow-create-sheet-type", index)) + .label(format!("New {name}")) .small() - .on_click(move |_, _, cx| create_aff_editor.update(cx, |editor, cx| editor.create_sheet_of_type(0, cx))), - ) - .child( - Button::new("flow-create-neg-sheet") - .label("New negative") - .small() - .on_click(move |_, _, cx| create_neg_editor.update(cx, |editor, cx| editor.create_sheet_of_type(1, cx))), - ) + .on_click(move |_, _, cx| editor.update(cx, |editor, cx| editor.create_sheet_of_type(index, cx))) + })) .child( Button::new("flow-move-sheet-left") .label("Move sheet left") @@ -157,28 +165,40 @@ impl Render for FlowRibbon { .label("Add argument") .small() .disabled(!has_active_sheet) - .on_click(move |_, _, cx| first_argument_editor.update(cx, |editor, cx| editor.add_first_argument(cx))), + .on_click(move |_, window, cx| first_argument_editor.update(cx, |editor, cx| { + editor.add_first_argument(cx); + editor.focus_active_cell(window, cx); + })), ) .child( Button::new("flow-add-response") .label("Add response") .small() .disabled(!has_active_cell) - .on_click(move |_, _, cx| response_editor.update(cx, |editor, cx| editor.add_response(cx))), + .on_click(move |_, window, cx| response_editor.update(cx, |editor, cx| { + editor.add_response(cx); + editor.focus_active_cell(window, cx); + })), ) .child( Button::new("flow-add-sibling-above") .label("Sibling above") .small() .disabled(!has_active_cell) - .on_click(move |_, _, cx| above_editor.update(cx, |editor, cx| editor.add_sibling(RelativePosition::Before, cx))), + .on_click(move |_, window, cx| above_editor.update(cx, |editor, cx| { + editor.add_sibling(RelativePosition::Before, cx); + editor.focus_active_cell(window, cx); + })), ) .child( Button::new("flow-add-sibling") .label("Add sibling") .small() .disabled(!has_active_cell) - .on_click(move |_, _, cx| below_editor.update(cx, |editor, cx| editor.add_sibling(RelativePosition::After, cx))), + .on_click(move |_, window, cx| below_editor.update(cx, |editor, cx| { + editor.add_sibling(RelativePosition::After, cx); + editor.focus_active_cell(window, cx); + })), ) .child( Button::new("flow-delete-selected") @@ -192,6 +212,7 @@ impl Render for FlowRibbon { Button::new("flow-strike-selected") .label("Strike") .small() + .selected(self.editor.read(cx).active_cell_is_struck()) .disabled(!has_active_cell) .on_click(move |_, _, cx| strike_editor.update(cx, |editor, cx| editor.strike_selected(cx))), ) @@ -199,13 +220,15 @@ impl Render for FlowRibbon { Button::new("flow-arm-marker") .label("Marker") .small() - .on_click(move |_, _, cx| marker_editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::Marker, cx))), + .selected(self.editor.read(cx).annotation_tool() == AnnotationTool::Marker) + .on_click(move |_, _, cx| marker_editor.update(cx, |editor, cx| editor.toggle_annotation_tool(AnnotationTool::Marker, cx))), ) .child( Button::new("flow-arm-eraser") .label("Eraser") .small() - .on_click(move |_, _, cx| eraser_editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::Eraser, cx))), + .selected(self.editor.read(cx).annotation_tool() == AnnotationTool::Eraser) + .on_click(move |_, _, cx| eraser_editor.update(cx, |editor, cx| editor.toggle_annotation_tool(AnnotationTool::Eraser, cx))), ) .child( Button::new("flow-toggle-annotations") diff --git a/crates/flowstate/src/workspace/workspace/keybindings.rs b/crates/flowstate/src/workspace/workspace/keybindings.rs index 5485875b..fc918854 100644 --- a/crates/flowstate/src/workspace/workspace/keybindings.rs +++ b/crates/flowstate/src/workspace/workspace/keybindings.rs @@ -46,15 +46,24 @@ impl Workspace { true }, CommandId::InsertNewline => { - flow.update(cx, |editor, cx| editor.add_sibling(flowstate_flow::RelativePosition::After, cx)); + flow.update(cx, |editor, cx| { + editor.add_sibling(flowstate_flow::RelativePosition::After, cx); + editor.focus_active_cell(window, cx); + }); true }, CommandId::InsertSoftLineBreak => { - flow.update(cx, |editor, cx| editor.add_response(cx)); + flow.update(cx, |editor, cx| { + editor.add_response(cx); + editor.focus_active_cell(window, cx); + }); true }, CommandId::FlowAddSiblingAbove => { - flow.update(cx, |editor, cx| editor.add_sibling(flowstate_flow::RelativePosition::Before, cx)); + flow.update(cx, |editor, cx| { + editor.add_sibling(flowstate_flow::RelativePosition::Before, cx); + editor.focus_active_cell(window, cx); + }); true }, CommandId::Backspace if flow.read(cx).active_cell_is_empty() => { diff --git a/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs b/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs index 762613d3..ab82d923 100644 --- a/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs +++ b/crates/gpui-flowtext/src/rich_text/benchmarks/layout.rs @@ -40,7 +40,7 @@ fn benchmark_layout_paths( let paint_bounds = Bounds::new(point(px(0.0), px(0.0)), size(width_px, layout.size.height)); let paint_plain = include_paint.then(|| { repeated(iterations, || { - paint_layout(&layout, paint_bounds, None, None, false, px(1.0), &[], &[], None, window, cx); + paint_layout(&layout, paint_bounds, None, None, false, px(1.0), None, &[], &[], None, window, cx); }) }); let selection = top_selection(document); @@ -53,6 +53,7 @@ fn benchmark_layout_paths( None, false, px(1.0), + None, &[], &[], None, diff --git a/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs b/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs index 7c0bda2a..2828d8c4 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/edit_pipeline.rs @@ -8,7 +8,7 @@ impl RichTextEditor { let Some(paragraph) = self.document.paragraphs.get(caret.paragraph) else { return false; }; - if self.invisibility_mode && matches!(paragraph.style, ParagraphStyle::Normal) { + if self.invisibility_mode && !paragraph_style_is_visible_for_theme(&self.document.theme, paragraph.style) { return false; } if caret.byte > paragraph_text_len(paragraph) { diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index d670eecf..d56b0b88 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -445,10 +445,13 @@ pub struct RichTextEditorStyleState { /// /// This is intentionally separate from document data. Future settings UI can /// edit this object without changing saved document content. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct RichTextEditorConfig { pub smart_word_selection: bool, pub allow_paragraph_breaks: bool, + pub flow_cell_surface: bool, + pub show_section_collapse_controls: bool, + pub caret_color: Option, } #[hotpath::measure_all] @@ -457,6 +460,9 @@ impl Default for RichTextEditorConfig { Self { smart_word_selection: true, allow_paragraph_breaks: true, + flow_cell_surface: false, + show_section_collapse_controls: true, + caret_color: None, } } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs index d5444fa7..ba08a137 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs @@ -9,7 +9,7 @@ impl RichTextEditor { self.last_drag_position = Some(event.position); self.goal_x = None; let offset = self.hit_test_document_position(event.position, window, cx); - if self.collapse_gutter_hit(event.position, offset.paragraph) { + if self.config.show_section_collapse_controls && self.collapse_gutter_hit(event.position, offset.paragraph) { self.toggle_section_collapsed_at_paragraph(offset.paragraph, &[0, 1, 2, 3], cx); return; } @@ -94,10 +94,9 @@ impl RichTextEditor { let paragraph_ix = self .hit_test_document_position(event.position, window, cx) .paragraph; - let next_hover = self - .section_collapsed_at_heading(paragraph_ix, &[0, 1, 2, 3]) - .is_some() - .then_some(paragraph_ix); + let next_hover = self.config.show_section_collapse_controls.then_some(paragraph_ix).filter(|paragraph_ix| { + self.section_collapsed_at_heading(*paragraph_ix, &[0, 1, 2, 3]).is_some() + }); if self.hovered_collapse_paragraph != next_hover { self.hovered_collapse_paragraph = next_hover; cx.notify(); diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index d093ea7b..4980f117 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -37,11 +37,14 @@ impl Render for RichTextEditor { let render_item_sizes = item_sizes.clone(); let render_items = render_layout.items.clone(); let hide_initial_layout = render_layout.hide_initial_layout; + let flow_cell_surface = self.config.flow_cell_surface; + let flow_cell_height = item_sizes.iter().fold(px(1.0), |height, item| height + item.height); div() - .size_full() .id("rich-text-editor") .relative() - .bg(self.document.theme.document_background_color) + .when(flow_cell_surface, |this| this.w_full().h(flow_cell_height)) + .when(!flow_cell_surface, |this| this.size_full()) + .when(!flow_cell_surface, |this| this.bg(self.document.theme.document_background_color)) .track_focus(&self.focus_handle(cx)) .key_context("RichTextEditor") .cursor(CursorStyle::IBeam) @@ -247,10 +250,10 @@ impl Render for RichTextEditor { .left_0() .right_0() .bottom_0() - .child( + .when(!flow_cell_surface, |this| this.child( Scrollbar::vertical(&self.scroll_handle) .scrollbar_show(ScrollbarShow::Always) - ), + )), ) } } diff --git a/crates/gpui-flowtext/src/rich_text/element.rs b/crates/gpui-flowtext/src/rich_text/element.rs index c719585b..45b13edb 100644 --- a/crates/gpui-flowtext/src/rich_text/element.rs +++ b/crates/gpui-flowtext/src/rich_text/element.rs @@ -161,7 +161,7 @@ impl Element for RichTextDocumentElement { cx: &mut App, ) { if let Some((layout, bounds)) = self.layout.positioned() { - paint_layout(layout.as_ref(), bounds, None, None, false, px(1.0), &[], &[], None, window, cx); + paint_layout(layout.as_ref(), bounds, None, None, false, px(1.0), None, &[], &[], None, window, cx); } } } @@ -239,7 +239,7 @@ impl Element for VirtualParagraphChunkElement { window: &mut Window, cx: &mut App, ) { - let (selection, drag_selection, caret_offset, caret_width, external_carets, search_highlights, active_search_highlight) = { + let (selection, drag_selection, caret_offset, caret_width, caret_color, external_carets, search_highlights, active_search_highlight) = { let editor = self.editor.read(cx); let drag_selection = editor.drag_source_selection(); let external_carets = editor.external_carets_for_paragraph(self.paragraph_ix); @@ -253,6 +253,7 @@ impl Element for VirtualParagraphChunkElement { && editor.focus_handle.is_focused(window)) .then_some(editor.selection.head), editor.caret_paint_width(), + editor.config().caret_color, external_carets, editor.search_highlights.clone(), editor.active_search_highlight, @@ -263,7 +264,10 @@ impl Element for VirtualParagraphChunkElement { let collapse_state = self .editor .read(cx) - .section_collapse_state_at_paragraph(self.paragraph_ix, &[0, 1, 2, 3]); + .config() + .show_section_collapse_controls + .then(|| self.editor.read(cx).section_collapse_state_at_paragraph(self.paragraph_ix, &[0, 1, 2, 3])) + .flatten(); if let Some(collapsed) = collapse_state { let indicator_width = px(9.0); let indicator_height = if collapsed { px(2.0) } else { px(9.0) }; @@ -298,6 +302,7 @@ impl Element for VirtualParagraphChunkElement { drag_selection.as_ref(), show_caret, caret_width, + caret_color, &external_carets, &search_highlights, active_search_highlight, diff --git a/crates/gpui-flowtext/src/rich_text/invisibility.rs b/crates/gpui-flowtext/src/rich_text/invisibility.rs index f612f7e5..18252a08 100644 --- a/crates/gpui-flowtext/src/rich_text/invisibility.rs +++ b/crates/gpui-flowtext/src/rich_text/invisibility.rs @@ -66,16 +66,8 @@ pub(super) fn paragraph_is_visible(document: &Document, paragraph: &Paragraph) - #[hotpath::measure] pub(super) fn paragraph_is_visible_for_theme(theme: &DocumentTheme, paragraph: &Paragraph) -> bool { - match paragraph.style { - ParagraphStyle::Normal => {}, - ParagraphStyle::Custom(slot) - if theme - .invisibility_visible_paragraph_styles - .contains(&(slot & 0x7f)) => - { - return true; - }, - ParagraphStyle::Custom(_) => {}, + if paragraph_style_is_visible_for_theme(theme, paragraph.style) { + return true; } paragraph .runs @@ -83,12 +75,19 @@ pub(super) fn paragraph_is_visible_for_theme(theme: &DocumentTheme, paragraph: & .any(|run| run_is_visible_for_theme(theme, run.styles)) } +pub(super) fn paragraph_style_is_visible_for_theme(theme: &DocumentTheme, style: ParagraphStyle) -> bool { + matches!( + style, + ParagraphStyle::Custom(slot) if theme.invisibility_visible_paragraph_styles.contains(&(slot & 0x7f)) + ) +} + pub(super) const INVISIBILITY_PROJECTED_VERSION_OFFSET: u64 = 0x9E37_79B9_7F4A_7C15; #[hotpath::measure] pub(super) fn invisibility_projected_document(document: &Document, paragraph_ix: usize) -> Option { let paragraph = document.paragraphs.get(paragraph_ix)?; - if !matches!(paragraph.style, ParagraphStyle::Normal) { + if paragraph_style_is_visible_for_theme(&document.theme, paragraph.style) { return None; } diff --git a/crates/gpui-flowtext/src/rich_text/layout/prep.rs b/crates/gpui-flowtext/src/rich_text/layout/prep.rs index 90f270c7..c4a2e38b 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/prep.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/prep.rs @@ -156,10 +156,10 @@ fn build_paragraph_prep_from_parts( edit_generation, }; - if invisibility_mode && matches!(paragraph.style, ParagraphStyle::Normal) { + if invisibility_mode && !paragraph_style_is_visible_for_theme(theme, paragraph.style) { let Some((text, runs)) = projected_visible_paragraph_text_and_runs_from_text(text, theme, paragraph, paragraph_byte_range.clone()) else { - // A Normal paragraph with no visible (cite/spoken/...) runs has nothing to - // project, so it is hidden rather than rendered as a blank visible line. + // A paragraph whose style is hidden and has no visible runs has nothing + // to project, so it is hidden rather than rendered as a blank line. return Some(ParagraphPrep { key, paragraph_ix, @@ -340,7 +340,7 @@ mod prep_tests { #[test] #[hotpath::measure] - fn invisibility_prep_projects_visible_runs() { + fn invisibility_prep_projects_visible_runs_from_hidden_custom_paragraphs() { let cite = RunStyles { semantic: RunSemanticStyle::Custom(1), ..RunStyles::default() @@ -355,7 +355,7 @@ mod prep_tests { let document = document_from_input( theme, vec![InputParagraph { - style: ParagraphStyle::Normal, + style: ParagraphStyle::Custom(9), runs: vec![ input_run("hidden ", RunStyles::default()), input_run("cite", cite), diff --git a/crates/gpui-flowtext/src/rich_text/paint.rs b/crates/gpui-flowtext/src/rich_text/paint.rs index 3a295249..fdf59ea5 100644 --- a/crates/gpui-flowtext/src/rich_text/paint.rs +++ b/crates/gpui-flowtext/src/rich_text/paint.rs @@ -12,6 +12,7 @@ pub(super) fn paint_layout( drag_selection: Option<&EditorSelection>, show_caret: bool, caret_width: Pixels, + caret_color: Option, external_carets: &[ExternalCaret], search_highlights: &[Range], active_search_highlight: Option, @@ -103,7 +104,7 @@ pub(super) fn paint_layout( && caret.intersects(&content_mask) { caret.size.width = caret_width; - window.paint_quad(fill(snap_vertical_rule_to_device_pixels(caret, window), black())); + window.paint_quad(fill(snap_vertical_rule_to_device_pixels(caret, window), caret_color.unwrap_or_else(black))); } for external_caret in external_carets { if let Some(mut caret) = caret_bounds(layout, external_caret.offset, bounds.origin) From 162b654368ca3425aa4aba3d26d0640038996c4a Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sat, 13 Jun 2026 23:31:52 -0500 Subject: [PATCH 07/24] Flowing UI usability --- crates/flowstate/src/flow/cell_theme.rs | 3 +- crates/flowstate/src/flow/editor.rs | 215 ++++++++++++------ .../flowstate/src/flow/editor/annotation.rs | 5 +- .../flowstate/src/flow/editor/cell_editing.rs | 12 +- crates/flowstate/src/flow/editor/layout.rs | 31 ++- .../src/workspace/workspace/documents.rs | 2 + .../src/workspace/workspace/keybindings.rs | 6 + .../src/workspace/workspace/render_status.rs | 6 +- .../src/workspace/workspace/zoom_status.rs | 4 + flowplan.md | 43 ++++ message-square-reply.svg | 1 + paniconwritetopdf.md | 0 12 files changed, 239 insertions(+), 89 deletions(-) create mode 100644 flowplan.md create mode 100644 message-square-reply.svg create mode 100644 paniconwritetopdf.md diff --git a/crates/flowstate/src/flow/cell_theme.rs b/crates/flowstate/src/flow/cell_theme.rs index 6592075f..31149d77 100644 --- a/crates/flowstate/src/flow/cell_theme.rs +++ b/crates/flowstate/src/flow/cell_theme.rs @@ -2,8 +2,9 @@ use flowstate_document::{Document, DocumentTheme}; use gpui::{Hsla, transparent_black}; use palette::{FromColor as _, LinSrgb, Oklch, Srgb}; -pub(super) fn apply_flow_cell_theme(document: &mut Document, client_theme: &DocumentTheme, foreground: Hsla, background: Hsla) { +pub(super) fn apply_flow_cell_theme(document: &mut Document, client_theme: &DocumentTheme, foreground: Hsla, background: Hsla, zoom: f32) { document.theme = client_theme.clone(); + document.theme.zoom_factor *= zoom; let source_default = document.theme.default_text_color; document.theme.default_text_color = foreground; document.theme.document_background_color = transparent_black(); diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 9861c771..844ea3c6 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -44,6 +44,13 @@ pub enum AnnotationTool { Eraser, } +struct PanDragState { + pointer_anchor: gpui::Point, + scroll_anchor: gpui::Point, + pending_position: gpui::Point, + frame_scheduled: bool, +} + pub struct FlowEditor { document: FlowDocument, path: Option, @@ -57,7 +64,7 @@ pub struct FlowEditor { local_annotation_originator: AnnotationOriginator, drawing_points: Vec, cell_editors: std::collections::HashMap>, - cell_editor_themes: std::collections::HashMap, + cell_editor_themes: std::collections::HashMap, cell_editor_subscriptions: std::collections::HashMap, pending_cell_drop: Option, cell_bounds: std::collections::HashMap>, @@ -65,7 +72,7 @@ pub struct FlowEditor { board_zoom: f32, viewport_origin: BoardPoint, space_pan_armed: bool, - pan_drag_anchor: Option<(gpui::Point, gpui::Point)>, + pan_drag: Option, focus_handle: FocusHandle, } @@ -101,7 +108,7 @@ impl FlowEditor { board_zoom: 1.0, viewport_origin: BoardPoint::default(), space_pan_armed: false, - pan_drag_anchor: None, + pan_drag: None, focus_handle: cx.focus_handle(), } } @@ -159,13 +166,27 @@ impl FlowEditor { self.board_zoom } - /// suggestion: wire future zoom controls here. Annotation and board-space - /// transforms already consume this value even though no zoom UI exists yet. pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut Context) { self.board_zoom = zoom.clamp(0.25, 4.0); cx.notify(); } + pub fn zoom_percent(&self) -> f32 { + self.board_zoom * 100.0 + } + + pub fn set_zoom_percent(&mut self, percent: f32, cx: &mut Context) { + self.set_board_zoom(percent / 100.0, cx); + } + + pub fn zoom_in(&mut self, cx: &mut Context) { + self.set_zoom_percent(self.zoom_percent() + 10.0, cx); + } + + pub fn zoom_out(&mut self, cx: &mut Context) { + self.set_zoom_percent(self.zoom_percent() - 10.0, cx); + } + pub fn visible_board_rect(&self) -> BoardRect { let bounds = self.board_scroll.bounds(); let offset = self.board_scroll.offset(); @@ -350,8 +371,9 @@ impl FlowEditor { } fn set_cell_bounds(&mut self, cell_id: CellId, bounds: Bounds, cx: &mut Context) { - if self.cell_bounds.get(&cell_id) != Some(&bounds) { - self.cell_bounds.insert(cell_id, bounds); + let size_changed = self.cell_bounds.get(&cell_id).is_none_or(|previous| previous.size != bounds.size); + self.cell_bounds.insert(cell_id, bounds); + if size_changed { cx.notify(); } } @@ -397,21 +419,39 @@ impl FlowEditor { .unwrap_or(cx.theme().foreground) } - fn begin_space_pan(&mut self, position: gpui::Point, cx: &mut Context) { - if self.space_pan_armed { - self.pan_drag_anchor = Some((position, self.board_scroll.offset())); - cx.notify(); - } + fn begin_pan(&mut self, position: gpui::Point, cx: &mut Context) { + self.pan_drag = Some(PanDragState { + pointer_anchor: position, + scroll_anchor: self.board_scroll.offset(), + pending_position: position, + frame_scheduled: false, + }); + cx.notify(); } - fn continue_space_pan(&mut self, position: gpui::Point) { - if let Some((start, offset)) = self.pan_drag_anchor { - self.board_scroll.set_offset(offset + (position - start)); + fn queue_pan(&mut self, position: gpui::Point, window: &mut Window, cx: &mut Context) { + let Some(pan_drag) = self.pan_drag.as_mut() else { + return; + }; + pan_drag.pending_position = position; + if pan_drag.frame_scheduled { + return; } + pan_drag.frame_scheduled = true; + cx.on_next_frame(window, |editor, _, cx| { + let Some(pan_drag) = editor.pan_drag.as_mut() else { + return; + }; + pan_drag.frame_scheduled = false; + editor + .board_scroll + .set_offset(pan_drag.scroll_anchor + (pan_drag.pending_position - pan_drag.pointer_anchor)); + cx.notify(); + }); } fn finish_space_pan(&mut self, cx: &mut Context) { - self.pan_drag_anchor = None; + self.pan_drag = None; cx.notify(); } @@ -554,19 +594,11 @@ impl FlowEditor { return div().child("Invalid sheet type").into_any_element(); }; let active = self.active_cell; - let visible_rect = self.visible_board_rect(); - let inflation = 32.0 / self.board_zoom; let strokes: Vec<_> = if !self.hidden_annotation_sheets.contains(&sheet_id) { sheet .annotations .iter() - .filter(|stroke| { - !self.hidden_annotation_originators.contains(&stroke.originator) - && stroke.bbox.max.x >= visible_rect.min.x - inflation - && stroke.bbox.min.x <= visible_rect.max.x + inflation - && stroke.bbox.max.y >= visible_rect.min.y - inflation - && stroke.bbox.min.y <= visible_rect.max.y + inflation - }) + .filter(|stroke| !self.hidden_annotation_originators.contains(&stroke.originator)) .cloned() .collect() } else { @@ -574,20 +606,21 @@ impl FlowEditor { }; let draft = self.drawing_points.clone(); let client_document_theme = load_document_theme(); - let cell_layout = sheet_cell_layout(sheet, &self.cell_bounds); - let board_width = px(32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0); + let zoom = self.board_zoom; + let cell_layout = sheet_cell_layout(sheet, &self.cell_bounds, zoom); + let board_width = px((32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0) * zoom); let weak_editor = cx.entity().downgrade(); let weak_connector_editor = weak_editor.clone(); + let mut children_by_parent: std::collections::HashMap> = std::collections::HashMap::new(); + for cell in &sheet.cells { + if let Some(parent) = cell.parent_id { + children_by_parent.entry(parent).or_default().push(cell.id); + } + } let connector_families = sheet .cells .iter() - .filter(|cell| sheet.cells.iter().any(|child| child.parent_id == Some(cell.id))) - .map(|parent| { - ( - parent.id, - sheet.cells.iter().filter(|child| child.parent_id == Some(parent.id)).map(|child| child.id).collect::>(), - ) - }) + .filter_map(|parent| children_by_parent.remove(&parent.id).map(|children| (parent.id, children))) .collect::>(); div() .id("flow-columns") @@ -596,15 +629,15 @@ impl FlowEditor { .min_h_full() .w(board_width) .flex() - .gap(px(16.0)) - .p(px(16.0)) + .gap(px(16.0 * zoom)) + .p(px(16.0 * zoom)) .children(definition.columns.iter().enumerate().map(|(column_index, column)| { let side_palette = flow_side_palette(column.side, cx); let side_color = side_palette.base; let can_receive_child = column_index + 1 < definition.columns.len(); let add_editor = cx.entity().clone(); div() - .w(px(280.0)) + .w(px(280.0 * zoom)) .flex_none() .flex_col() .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { @@ -649,7 +682,7 @@ impl FlowEditor { ), ), ) - .child(div().h(px(12.0)).flex_none()) + .child(div().h(px(12.0 * zoom)).flex_none()) .children({ let mut previous_bottom = 0.0; sheet.cells.iter().filter(|cell| cell.column_id == column.id).map(|cell| { @@ -671,7 +704,7 @@ impl FlowEditor { document.blocks = std::sync::Arc::new(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs)); uses_summary_projection = true; } - apply_flow_cell_theme(document, &client_document_theme, side_color, cx.theme().background); + apply_flow_cell_theme(document, &client_document_theme, side_color, cx.theme().background, self.board_zoom); } let cell_editor = self.cell_editors.get(&id).cloned(); let reply_editor = cx.entity().clone(); @@ -695,9 +728,9 @@ impl FlowEditor { .id(("flow-cell", id.as_u128() as u64)) .relative() .w_full() - .min_h(px(54.0)) - .p(px(10.0)) - .rounded(px(6.0)) + .min_h(px(54.0 * zoom)) + .p(px(10.0 * zoom)) + .rounded(px(6.0 * zoom)) .border_1() .border_color(if active == Some(id) { side_palette.active @@ -714,8 +747,8 @@ impl FlowEditor { .on_mouse_down(MouseButton::Left, cx.listener(|editor, _, _, cx| { if editor.annotation_tool != AnnotationTool::None { editor.set_annotation_tool(AnnotationTool::None, cx); - cx.stop_propagation(); } + cx.stop_propagation(); })) .on_click(cx.listener(move |editor, _, window, cx| { editor.activate_cell(id, cx); @@ -762,11 +795,14 @@ impl FlowEditor { .ghost() .tooltip("Add first reply") .child(Icon::default().path("icons/message-square-reply.svg").xsmall()) - .on_click(move |_, window, cx| reply_editor.update(cx, |editor, cx| { - editor.set_annotation_tool(AnnotationTool::None, cx); - editor.add_first_response_to(id, cx); - editor.focus_active_cell(window, cx); - })), + .on_click(move |_, window, cx| { + cx.stop_propagation(); + reply_editor.update(cx, |editor, cx| { + editor.set_annotation_tool(AnnotationTool::None, cx); + editor.add_first_response_to(id, cx); + editor.focus_active_cell(window, cx); + }); + }), ) }) .child(if active == Some(id) { @@ -796,20 +832,38 @@ impl FlowEditor { let Some(first_child) = children.first() else { continue; }; - let start = point(parent.right(), parent.center().y); - let midpoint = start.x + (first_child.left() - start.x) / 2.0; - let lowest_y = children.iter().map(|child| child.center().y).max().unwrap_or(start.y); - let mut path = PathBuilder::stroke(px(1.5)); + let scale = window.scale_factor(); + let snap = |value: gpui::Pixels| px(((value.as_f32() * scale).floor() + 0.5) / scale); + let start = point(snap(parent.right()), snap(parent.center().y)); + let midpoint = snap(start.x + (first_child.left() - start.x) / 2.0); + let first_left = snap(first_child.left()); + if children.len() == 1 { + let mut path = PathBuilder::stroke(px(1.0)); + path.move_to(start); + path.line_to(point(first_left, start.y)); + if let Ok(path) = path.build() { + window.paint_path(path, cx.theme().foreground); + } + continue; + } + let lowest_y = children.iter().skip(1).map(|child| snap(child.center().y)).max().unwrap_or(start.y); + let radius = cx.theme().radius.min(px(10.0)).min((lowest_y - start.y).abs()).min((first_child.left() - midpoint).abs()); + let mut path = PathBuilder::stroke(px(1.0)); path.move_to(start); - path.line_to(point(midpoint, start.y)); - path.line_to(point(midpoint, lowest_y)); - for child in children { - let end = point(child.left(), child.center().y); - path.move_to(point(midpoint, end.y)); - path.line_to(end); + path.line_to(point(first_left, start.y)); + path.move_to(point(midpoint, start.y)); + path.line_to(point(midpoint, lowest_y - radius)); + path.curve_to(point(midpoint + radius, lowest_y), point(midpoint, lowest_y)); + path.line_to(point(snap(children.last().unwrap().left()), lowest_y)); + for child in children.iter().skip(1) { + let child_y = snap(child.center().y); + if child_y != lowest_y { + path.move_to(point(midpoint, child_y)); + path.line_to(point(snap(child.left()), child_y)); + } } if let Ok(path) = path.build() { - window.paint_path(path, cx.theme().secondary); + window.paint_path(path, cx.theme().foreground); } } }, @@ -831,13 +885,14 @@ impl FlowEditor { paint_stroke( bounds.origin, &stroke.points, - px(stroke.style.width), + px(stroke.style.width * zoom), color.opacity(color.a * stroke.style.opacity), + zoom, window, ); } if !draft.is_empty() { - paint_stroke(bounds.origin, &draft, px(4.0), gpui::Hsla::from(rgba(0xf59e_0bff)).opacity(0.55), window); + paint_stroke(bounds.origin, &draft, px(4.0 * zoom), gpui::Hsla::from(rgba(0xf59e_0bff)).opacity(0.55), zoom, window); } }, ) @@ -849,14 +904,21 @@ impl FlowEditor { } } -fn paint_stroke(origin: gpui::Point, points: &[BoardPoint], width: gpui::Pixels, color: gpui::Hsla, window: &mut Window) { +fn paint_stroke( + origin: gpui::Point, + points: &[BoardPoint], + width: gpui::Pixels, + color: gpui::Hsla, + zoom: f32, + window: &mut Window, +) { let Some(first) = points.first() else { return; }; let mut path = PathBuilder::stroke(width); - path.move_to(point(origin.x + px(first.x), origin.y + px(first.y))); + path.move_to(point(origin.x + px(first.x * zoom), origin.y + px(first.y * zoom))); for point_value in &points[1..] { - path.line_to(point(origin.x + px(point_value.x), origin.y + px(point_value.y))); + path.line_to(point(origin.x + px(point_value.x * zoom), origin.y + px(point_value.y * zoom))); } if let Ok(path) = path.build() { window.paint_path(path, color); @@ -872,8 +934,16 @@ impl Focusable for FlowEditor { } impl Render for FlowEditor { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.refresh_active_cell_theme(cx); + if self.pan_drag.is_some() { + let editor = cx.entity(); + window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| { + if phase.bubble() && event.button == MouseButton::Left { + editor.update(cx, |editor, cx| editor.finish_space_pan(cx)); + } + }); + } let grid_scroll = self.board_scroll.clone(); let board_zoom = self.board_zoom; div() @@ -897,11 +967,11 @@ impl Render for FlowEditor { })) .on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| { if editor.space_pan_armed { - editor.begin_space_pan(event.position, cx); + editor.begin_pan(event.position, cx); cx.stop_propagation(); } })) - .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, _| editor.continue_space_pan(event.position))) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, window, cx| editor.queue_pan(event.position, window, cx))) .on_mouse_up(MouseButton::Left, cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_space_pan(cx))) .child( canvas( @@ -934,6 +1004,17 @@ impl Render for FlowEditor { .size_full() .overflow_scroll() .track_scroll(&self.board_scroll) + .cursor(if self.pan_drag.is_some() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .when(self.annotation_tool == AnnotationTool::None && !self.space_pan_armed, |this| { + this.on_mouse_down(MouseButton::Left, cx.listener(|editor, event: &MouseDownEvent, _, cx| { + editor.begin_pan(event.position, cx); + cx.stop_propagation(); + })) + }) .on_scroll_wheel(cx.listener(|editor, event: &ScrollWheelEvent, window, cx| { if event.modifiers.shift { let delta = event.delta.pixel_delta(window.line_height()); diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs index a4b01f7d..db1e701b 100644 --- a/crates/flowstate/src/flow/editor/annotation.rs +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -45,10 +45,9 @@ impl FlowEditor { } fn annotation_point(&self, position: Point) -> BoardPoint { - let offset = self.board_scroll.offset(); BoardPoint { - x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom - offset.x.as_f32(), - y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom - offset.y.as_f32(), + x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom, + y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom, } } diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs index 635715f6..28993c55 100644 --- a/crates/flowstate/src/flow/editor/cell_editing.rs +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -22,7 +22,7 @@ impl FlowEditor { return; }; let text_color = self.cell_text_color(cell_id, cx); - apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background); + apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background, self.board_zoom()); let editor = cx.new(|cx| { let mut editor = RichTextEditor::new_with_path(document, None, cx); editor.set_invisibility_mode(uses_summary_projection, cx); @@ -72,7 +72,7 @@ impl FlowEditor { } }); self.cell_editors.insert(cell_id, editor); - self.cell_editor_themes.insert(cell_id, (text_color, cx.theme().background)); + self.cell_editor_themes.insert(cell_id, (text_color, cx.theme().background, self.board_zoom().to_bits())); self.cell_editor_subscriptions.insert(cell_id, subscription); } @@ -93,11 +93,11 @@ impl FlowEditor { let current = flowstate_document::db8_bytes(editor.read(cx).document()).ok(); let mut themed_document = document.clone(); let text_color = self.cell_text_color(*cell_id, cx); - apply_flow_cell_theme(&mut themed_document, &client_theme, text_color, cx.theme().background); + apply_flow_cell_theme(&mut themed_document, &client_theme, text_color, cx.theme().background, self.board_zoom()); let desired = flowstate_document::db8_bytes(&themed_document).ok(); if current != desired { editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(themed_document, cx)); - self.cell_editor_themes.insert(*cell_id, (text_color, cx.theme().background)); + self.cell_editor_themes.insert(*cell_id, (text_color, cx.theme().background, self.board_zoom().to_bits())); } } } @@ -116,7 +116,7 @@ impl FlowEditor { return; }; let text_color = self.cell_text_color(cell_id, cx); - let signature = (text_color, cx.theme().background); + let signature = (text_color, cx.theme().background, self.board_zoom().to_bits()); if self.cell_editor_themes.get(&cell_id) == Some(&signature) { return; } @@ -131,7 +131,7 @@ impl FlowEditor { else { return; }; - apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background); + apply_flow_cell_theme(&mut document, &load_document_theme(), text_color, cx.theme().background, self.board_zoom()); editor.update(cx, |editor, cx| editor.replace_document_from_collaboration(document, cx)); self.cell_editor_themes.insert(cell_id, signature); } diff --git a/crates/flowstate/src/flow/editor/layout.rs b/crates/flowstate/src/flow/editor/layout.rs index ee639916..20955be4 100644 --- a/crates/flowstate/src/flow/editor/layout.rs +++ b/crates/flowstate/src/flow/editor/layout.rs @@ -12,24 +12,33 @@ pub(super) struct CellLayout { pub height: f32, } -pub(super) fn sheet_cell_layout(sheet: &Sheet, bounds: &HashMap>) -> HashMap { +pub(super) fn sheet_cell_layout(sheet: &Sheet, bounds: &HashMap>, zoom: f32) -> HashMap { + let cells = sheet.cells.iter().map(|cell| (cell.id, cell)).collect::>(); + let mut children: HashMap> = HashMap::new(); + for cell in &sheet.cells { + if let Some(parent) = cell.parent_id { + children.entry(parent).or_default().push(cell.id); + } + } let mut layout = HashMap::with_capacity(sheet.cells.len()); let mut top = 0.0; for root in sheet.cells.iter().filter(|cell| cell.parent_id.is_none()) { - let family_height = layout_family(sheet, root.id, top, bounds, &mut layout); - top += family_height + CELL_GAP; + let family_height = layout_family(root.id, top, &cells, &children, bounds, zoom, &mut layout); + top += family_height + CELL_GAP * zoom; } layout } fn layout_family( - sheet: &Sheet, cell_id: CellId, top: f32, + cells: &HashMap, + children: &HashMap>, bounds: &HashMap>, + zoom: f32, layout: &mut HashMap, ) -> f32 { - let Some(cell) = sheet.cells.iter().find(|cell| cell.id == cell_id) else { + let Some(cell) = cells.get(&cell_id) else { return 0.0; }; let cell_height = measured_cell_height(cell, bounds); @@ -37,13 +46,13 @@ fn layout_family( let mut children_top = top; let mut children_height = 0.0; - for child in sheet.cells.iter().filter(|child| child.parent_id == Some(cell_id)) { - let child_height = layout_family(sheet, child.id, children_top, bounds, layout); - children_top += child_height + CELL_GAP; - children_height += child_height + CELL_GAP; + for child_id in children.get(&cell_id).into_iter().flatten() { + let child_height = layout_family(*child_id, children_top, cells, children, bounds, zoom, layout); + children_top += child_height + CELL_GAP * zoom; + children_height += child_height + CELL_GAP * zoom; } if children_height > 0.0 { - children_height -= CELL_GAP; + children_height -= CELL_GAP * zoom; } cell_height.max(children_height) } @@ -87,7 +96,7 @@ mod tests { annotations: Vec::new(), }; - let layout = sheet_cell_layout(&sheet, &HashMap::new()); + let layout = sheet_cell_layout(&sheet, &HashMap::new(), 1.0); assert_eq!(layout[&root.id].top, 0.0); assert_eq!(layout[&orphan.id].top, 2.0 * 54.0 + 2.0 * CELL_GAP); diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index eb6d9758..bbdc951e 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -16,6 +16,8 @@ impl Workspace { editor.update(cx, |editor, cx| { editor.set_zoom_percent(*percent, cx); }); + } else if let Some(flow) = workspace.active_flow.clone() { + flow.update(cx, |flow, cx| flow.set_zoom_percent(*percent, cx)); } }); let workspace = cx.entity().downgrade(); diff --git a/crates/flowstate/src/workspace/workspace/keybindings.rs b/crates/flowstate/src/workspace/workspace/keybindings.rs index fc918854..edb726c3 100644 --- a/crates/flowstate/src/workspace/workspace/keybindings.rs +++ b/crates/flowstate/src/workspace/workspace/keybindings.rs @@ -106,6 +106,9 @@ impl Workspace { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_in(cx)); true + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); + true } else { false } @@ -114,6 +117,9 @@ impl Workspace { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_out(cx)); true + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); + true } else { false } diff --git a/crates/flowstate/src/workspace/workspace/render_status.rs b/crates/flowstate/src/workspace/workspace/render_status.rs index a7543e68..156c13c8 100644 --- a/crates/flowstate/src/workspace/workspace/render_status.rs +++ b/crates/flowstate/src/workspace/workspace/render_status.rs @@ -263,7 +263,11 @@ impl Workspace { } else { None }; - let zoom = self.active_editor.as_ref().map(|editor| editor.read(cx).zoom_percent()); + let zoom = self + .active_editor + .as_ref() + .map(|editor| editor.read(cx).zoom_percent()) + .or_else(|| self.active_flow.as_ref().map(|flow| flow.read(cx).zoom_percent())); if let Some(percent) = zoom { self.sync_zoom_slider(percent, window, cx); } diff --git a/crates/flowstate/src/workspace/workspace/zoom_status.rs b/crates/flowstate/src/workspace/workspace/zoom_status.rs index 8612ee01..b5f6ef1e 100644 --- a/crates/flowstate/src/workspace/workspace/zoom_status.rs +++ b/crates/flowstate/src/workspace/workspace/zoom_status.rs @@ -3,12 +3,16 @@ impl Workspace { fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_in(cx)); + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); } } fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_out(cx)); + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); } } diff --git a/flowplan.md b/flowplan.md new file mode 100644 index 00000000..f7c61e3f --- /dev/null +++ b/flowplan.md @@ -0,0 +1,43 @@ +• I can identify several concrete causes from the code. Visual data would help prioritize them, but is not required to establish that the current implementation is structurally unreliable. + + Primary Issues + + 1. Nested drop targets fight each other + - Every cell and its containing column have on_drag_move and on_drop. + - Cell drag-move stops propagation, but column behavior still wins whenever the pointer crosses small gaps, controls, or card edges. + - pending_cell_drop constantly switches between cell-relative and orphan-column destinations. + + 2. No drag-leave cleanup + - pending_cell_drop remains set after leaving a valid target. + - Dropping later can execute the last stale destination rather than the visible/current target. + + 3. Drop zones are too coarse + - A cell’s rightmost 28% means “child.” + - Everything else is split at one vertical midpoint. + - This makes intended top/bottom drops difficult on short cards and child drops easy to trigger accidentally. + + 4. Column insertion uses global sheet indexes incorrectly + - update_column_drop derives an index from cells in one column, then uses the corresponding global sheet index. + - Because sheet order mixes families and columns, the destination can differ substantially from the pointer’s visible vertical location. + + 5. Gap inheritance is inconsistent + - A gap becomes a sibling drop only when adjacent same-column cells share a parent. + - Otherwise it becomes an orphan, even when visually inside a family run. + + 6. Moving a parent destroys its existing family + - move_cell unconditionally orphans all direct children. + - Dragging a parent therefore moves only the parent, causing large unexpected layout changes. + + 7. No invalid-drop validation before preview + - The UI can preview child drops onto descendants, structurally invalid columns, or destinations that effectively do nothing. + - Failure occurs silently when the model rejects or normalizes it. + + 8. No explicit drop indicator geometry + - Only the target card border changes. + - Users cannot reliably distinguish before, after, child, or orphan insertion positions. + + 9. Every drag movement triggers a full render + - update_cell_drop always assigns and calls cx.notify(), even when the destination did not change. + - This contributes to dodgy responsiveness. + + So yes, the implementation itself explains the general unreliability. Visual examples would only help confirm which behavior is most disruptive. diff --git a/message-square-reply.svg b/message-square-reply.svg new file mode 100644 index 00000000..7c33d195 --- /dev/null +++ b/message-square-reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/paniconwritetopdf.md b/paniconwritetopdf.md new file mode 100644 index 00000000..e69de29b From 896b684cb015fe4fa053fa06363a395ea770dc05 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Sun, 14 Jun 2026 01:13:30 -0500 Subject: [PATCH 08/24] Trying to fix zoom --- crates/flowstate/src/flow/editor.rs | 96 ++++++++++++++++--- .../src/workspace/workspace/traits.rs | 6 ++ .../src/rich_text/editor/item_sizes.rs | 2 + .../src/rich_text/editor/lifecycle.rs | 2 + .../gpui-flowtext/src/rich_text/editor/mod.rs | 2 + .../src/rich_text/editor/scroll_anchor.rs | 13 +-- .../src/rich_text/editor/traits.rs | 7 ++ .../src/rich_text/editor/zoom.rs | 18 ++++ flowplan.md | 2 + 9 files changed, 128 insertions(+), 20 deletions(-) diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 844ea3c6..c2bb8962 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -70,6 +70,9 @@ pub struct FlowEditor { cell_bounds: std::collections::HashMap>, board_scroll: ScrollHandle, board_zoom: f32, + camera_center: Option, + camera_apply_pending: bool, + board_content_width: gpui::Pixels, viewport_origin: BoardPoint, space_pan_armed: bool, pan_drag: Option, @@ -106,6 +109,9 @@ impl FlowEditor { cell_bounds: std::collections::HashMap::new(), board_scroll: ScrollHandle::new(), board_zoom: 1.0, + camera_center: None, + camera_apply_pending: false, + board_content_width: px(0.0), viewport_origin: BoardPoint::default(), space_pan_armed: false, pan_drag: None, @@ -167,10 +173,56 @@ impl FlowEditor { } pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut Context) { - self.board_zoom = zoom.clamp(0.25, 4.0); + let zoom = zoom.clamp(0.25, 4.0); + if (self.board_zoom - zoom).abs() < f32::EPSILON { + return; + } + let viewport = self.board_scroll.bounds().size; + let offset = self.board_scroll.offset(); + let center = self.camera_center.unwrap_or(BoardPoint { + x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / self.board_zoom, + y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / self.board_zoom, + }); + self.board_zoom = zoom; + self.camera_center = Some(center); + self.camera_apply_pending = true; cx.notify(); } + fn apply_camera_center(&mut self) { + if !self.camera_apply_pending { + self.sync_camera_center_from_scroll(); + return; + } + let Some(center) = self.camera_center else { + return; + }; + let viewport = self.board_scroll.bounds().size; + let max_offset = self.board_scroll.max_offset(); + let max_x = (self.board_content_width - viewport.width).max(px(0.0)); + let x = px(-(center.x * self.board_zoom - viewport.width.as_f32() / 2.0)) + .min(px(0.0)) + .max(-max_x); + let y = px(-(center.y * self.board_zoom - viewport.height.as_f32() / 2.0)) + .min(px(0.0)) + .max(-max_offset.height); + if self.board_scroll.offset() != point(x, y) { + self.board_scroll.set_offset(point(x, y)); + } + self.camera_apply_pending = false; + self.sync_camera_center_from_scroll(); + self.camera_center = None; + } + + fn sync_camera_center_from_scroll(&mut self) { + let viewport = self.board_scroll.bounds().size; + let offset = self.board_scroll.offset(); + self.camera_center = Some(BoardPoint { + x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / self.board_zoom, + y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / self.board_zoom, + }); + } + pub fn zoom_percent(&self) -> f32 { self.board_zoom * 100.0 } @@ -446,6 +498,7 @@ impl FlowEditor { editor .board_scroll .set_offset(pan_drag.scroll_anchor + (pan_drag.pending_position - pan_drag.pointer_anchor)); + editor.sync_camera_center_from_scroll(); cx.notify(); }); } @@ -607,8 +660,11 @@ impl FlowEditor { let draft = self.drawing_points.clone(); let client_document_theme = load_document_theme(); let zoom = self.board_zoom; + let control_size = px(20.0 * zoom); + let control_icon_size = px(12.0 * zoom); let cell_layout = sheet_cell_layout(sheet, &self.cell_bounds, zoom); let board_width = px((32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0) * zoom); + let measured_board_width = board_width; let weak_editor = cx.entity().downgrade(); let weak_connector_editor = weak_editor.clone(); let mut children_by_parent: std::collections::HashMap> = std::collections::HashMap::new(); @@ -623,6 +679,15 @@ impl FlowEditor { .filter_map(|parent| children_by_parent.remove(&parent.id).map(|children| (parent.id, children))) .collect::>(); div() + .on_children_prepainted({ + let weak_editor = weak_editor.clone(); + move |_, _, cx| { + let _ = weak_editor.update(cx, |editor, _| { + editor.board_content_width = measured_board_width; + editor.apply_camera_center(); + }); + } + }) .id("flow-columns") .relative() .min_w_full() @@ -650,6 +715,7 @@ impl FlowEditor { .items_center() .justify_between() .font_weight(gpui::FontWeight::BOLD) + .text_size(px(14.0 * zoom)) .text_color(side_color) .border_b_2() .border_color(side_color) @@ -657,18 +723,18 @@ impl FlowEditor { .child( div() .flex() - .gap_1() + .gap(px(4.0 * zoom)) .child( Button::new(("flow-send-to-document", column_index)) - .xsmall() + .with_size(control_size) .ghost() .tooltip("Send to document") - .child(Icon::default().path("icons/file-input.svg").xsmall()) + .child(Icon::default().path("icons/file-input.svg").with_size(control_icon_size)) .on_click(|_, _, _| {}), ) .child( Button::new(("flow-add-column-orphan", column_index)) - .xsmall() + .with_size(control_size) .ghost() .tooltip("Add orphan at top") .icon(IconName::Plus) @@ -777,8 +843,9 @@ impl FlowEditor { div() .id(("flow-cell-drag-handle", id.as_u128() as u64)) .absolute() - .top(px(2.0)) - .right(px(4.0)) + .top(px(2.0 * zoom)) + .right(px(4.0 * zoom)) + .text_size(px(14.0 * zoom)) .cursor_move() .text_color(cx.theme().muted_foreground) .child("⠿") @@ -789,12 +856,12 @@ impl FlowEditor { this.child( Button::new(("flow-cell-reply", id.as_u128() as u64)) .absolute() - .top(px(24.0)) - .right(px(2.0)) - .xsmall() + .top(px(24.0 * zoom)) + .right(px(2.0 * zoom)) + .with_size(control_size) .ghost() .tooltip("Add first reply") - .child(Icon::default().path("icons/message-square-reply.svg").xsmall()) + .child(Icon::default().path("icons/message-square-reply.svg").with_size(control_icon_size)) .on_click(move |_, window, cx| { cx.stop_propagation(); reply_editor.update(cx, |editor, cx| { @@ -978,6 +1045,7 @@ impl Render for FlowEditor { |_, _, _| {}, move |bounds, _, window, cx| { let spacing = px(24.0 * board_zoom); + let dot_size = px(1.5 * board_zoom); let offset = grid_scroll.offset(); let mut x = offset.x % spacing; let color = cx.theme().border.opacity(0.56); @@ -985,7 +1053,7 @@ impl Render for FlowEditor { let mut y = offset.y % spacing; while y < bounds.size.height { window.paint_quad(gpui::fill( - gpui::Bounds::new(bounds.origin + point(x, y), gpui::size(px(1.5), px(1.5))), + gpui::Bounds::new(bounds.origin + point(x, y), gpui::size(dot_size, dot_size)), color, )); y += spacing; @@ -1021,8 +1089,12 @@ impl Render for FlowEditor { let mut offset = editor.board_scroll.offset(); offset.x += delta.y + delta.x; editor.board_scroll.set_offset(offset); + editor.sync_camera_center_from_scroll(); cx.stop_propagation(); cx.notify(); + } else if !event.modifiers.control { + editor.camera_center = None; + cx.on_next_frame(window, |editor, _, _| editor.sync_camera_center_from_scroll()); } })) .when(self.annotation_tool != AnnotationTool::None && !self.space_pan_armed, |this| { diff --git a/crates/flowstate/src/workspace/workspace/traits.rs b/crates/flowstate/src/workspace/workspace/traits.rs index 4bea6a21..a7313fd3 100644 --- a/crates/flowstate/src/workspace/workspace/traits.rs +++ b/crates/flowstate/src/workspace/workspace/traits.rs @@ -13,6 +13,12 @@ impl Render for Workspace { } else { editor.update(cx, |editor, cx| editor.zoom_out(cx)); } + } else if let Some(flow) = workspace.active_flow.clone() { + if delta.y < px(0.0) { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); + } else { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); + } } }); } diff --git a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs index 7a3767ca..11a192a1 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs @@ -89,6 +89,7 @@ impl RichTextEditor { sizes: sizes.clone(), }); self.restore_scroll_anchor(scroll_anchor); + self.apply_pending_zoom_center(); if schedule_prefetch { self.schedule_chunk_prefetch(width, window, cx); } @@ -180,6 +181,7 @@ impl RichTextEditor { } self.pending_item_sizes_patch_range = None; self.restore_scroll_anchor(scroll_anchor); + self.apply_pending_zoom_center(); self.schedule_chunk_prefetch(width, window, cx); Some(patched_sizes) } diff --git a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs index 315f3d34..208efae9 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs @@ -33,6 +33,8 @@ impl RichTextEditor { last_send_document_generation: None, last_format_export_generation: None, zoom_percent: 100.0, + zoom_scroll_anchor: None, + zoom_anchor_apply_pending: false, save_status: SaveStatus::Saved, undo_stack: Vec::new(), redo_stack: Vec::new(), diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index d56b0b88..f28bcc16 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -873,6 +873,8 @@ pub struct RichTextEditor { last_send_document_generation: Option, last_format_export_generation: Option, zoom_percent: f32, + zoom_scroll_anchor: Option, + zoom_anchor_apply_pending: bool, save_status: SaveStatus, undo_stack: Vec, redo_stack: Vec, diff --git a/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs b/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs index 8d0b6ba3..c537be8a 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs @@ -47,16 +47,13 @@ impl RichTextEditor { let delta = (content_y - item_top).max(px(0.0)); match item { VirtualItem::ParagraphRemainder { paragraph_ix, .. } => { - let width = self.current_layout_width(); - let start_byte = self - .valid_chunk_cache_entry(paragraph_ix, width) - .and_then(|entry| entry.chunks.last()) - .map(|chunk| chunk.end_byte) - .unwrap_or(0); + let block_ix = self.block_ix_for_paragraph(paragraph_ix)?; + let paragraph_start_item = self.item_sizes_cache.as_ref()?.block_item_ranges.get(block_ix)?.start; + let paragraph_top = self.height_prefix_index.item_top(paragraph_start_item); Some(ScrollAnchorSnapshot::ParagraphRemainder { paragraph_ix, - start_byte, - delta, + start_byte: 0, + delta: (content_y - paragraph_top).max(px(0.0)), }) }, item => Some(ScrollAnchorSnapshot::Item { item, delta }), diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index 4980f117..013b14e3 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -119,6 +119,13 @@ impl Render for RichTextEditor { .on_mouse_move(cx.listener(Self::on_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_scroll_wheel(cx.listener(|editor, event: &gpui::ScrollWheelEvent, window, cx| { + if !event.modifiers.control { + editor.zoom_scroll_anchor = None; + editor.zoom_anchor_apply_pending = false; + cx.on_next_frame(window, |editor, _, _| editor.sync_camera_center_from_scroll()); + } + })) .drag_over::(|style, _, _, cx| style.border_1().border_color(cx.theme().drag_border)) .on_drag_move(cx.listener(Self::on_toolkit_text_drag_move)) .on_drop(cx.listener(Self::on_toolkit_text_drop)) diff --git a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs index 67c5cbac..903fdd11 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs @@ -13,12 +13,30 @@ impl RichTextEditor { if (self.zoom_percent - percent).abs() < f32::EPSILON { return; } + if self.zoom_scroll_anchor.is_none() { + self.zoom_scroll_anchor = self.capture_scroll_anchor(); + } self.zoom_percent = percent; self.document.theme.zoom_factor = percent / 100.0; + self.zoom_anchor_apply_pending = true; self.invalidate_document_layout_caches(); cx.notify(); } + pub(super) fn apply_pending_zoom_center(&mut self) { + if !self.zoom_anchor_apply_pending { + return; + } + self.restore_scroll_anchor(self.zoom_scroll_anchor.clone()); + self.zoom_anchor_apply_pending = false; + self.zoom_scroll_anchor = None; + } + + pub(super) fn sync_camera_center_from_scroll(&mut self) { + self.zoom_scroll_anchor = None; + self.zoom_anchor_apply_pending = false; + } + fn zoom_by(&mut self, delta_percent: f32, cx: &mut Context) { self.set_zoom_percent(self.zoom_percent + delta_percent, cx); } diff --git a/flowplan.md b/flowplan.md index f7c61e3f..c6852dd7 100644 --- a/flowplan.md +++ b/flowplan.md @@ -41,3 +41,5 @@ - This contributes to dodgy responsiveness. So yes, the implementation itself explains the general unreliability. Visual examples would only help confirm which behavior is most disruptive. + +1. Ok, the zoom is still not maintaining fidelity, but I think I have visual data that might help pin the problem down. For the flow, if I zoom out to minimum, then zoom in as fast as I can, I go to the far far right of the flow when zoomed in. But if I zoom in slower, I go left of that---and the slower I go, the more left I end up. At the absolutely slowest I tried, the screen found the rough midpoint. 2. In the middle of the zooming process there's a lot of wobbly snapping back and forth where elements on the screen will tick their position between two seeming states rather than smoothly scaling. 3. For the dot grid, they sort of seem to change position or shift around as zoom occurs, but I can't pin down the rule it follows---but as I zoom in, the dots get fainter and less defined, and as I zoom out, they become pronounced and very defined---logically, the opposite should happen---zooming on the dots should make them bigger. I presume this is because the dots are point-like, and they just represent as points on whatever the given zoom is? Or is it a more complex explanation? Regardless, the dot sizing at 100% zoom is perfect, it would be great if that scaled with the zoom logically. From 80d274b6ac3a18b57e98ca82332410505fa895af Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Mon, 15 Jun 2026 05:27:41 -0500 Subject: [PATCH 09/24] Zoom fixed. --- crates/flowstate/src/flow/cell_theme.rs | 28 +- crates/flowstate/src/flow/editor.rs | 216 +++------------ .../flowstate/src/flow/editor/annotation.rs | 23 +- .../flowstate/src/flow/editor/cell_editing.rs | 2 + crates/flowstate/src/flow/editor/connector.rs | 77 ++++++ crates/flowstate/src/flow/editor/layout.rs | 92 ++++++- crates/flowstate/src/flow/editor/zoom.rs | 142 ++++++++++ .../src/rich_text/editor/item_sizes.rs | 7 +- .../src/rich_text/editor/layout_access.rs | 2 + .../src/rich_text/editor/lifecycle.rs | 4 +- .../gpui-flowtext/src/rich_text/editor/mod.rs | 2 +- .../src/rich_text/editor/traits.rs | 5 +- .../src/rich_text/editor/zoom.rs | 251 +++++++++++++++++- 13 files changed, 645 insertions(+), 206 deletions(-) create mode 100644 crates/flowstate/src/flow/editor/connector.rs create mode 100644 crates/flowstate/src/flow/editor/zoom.rs diff --git a/crates/flowstate/src/flow/cell_theme.rs b/crates/flowstate/src/flow/cell_theme.rs index 31149d77..63029169 100644 --- a/crates/flowstate/src/flow/cell_theme.rs +++ b/crates/flowstate/src/flow/cell_theme.rs @@ -5,6 +5,7 @@ use palette::{FromColor as _, LinSrgb, Oklch, Srgb}; pub(super) fn apply_flow_cell_theme(document: &mut Document, client_theme: &DocumentTheme, foreground: Hsla, background: Hsla, zoom: f32) { document.theme = client_theme.clone(); document.theme.zoom_factor *= zoom; + scale_flow_layout_metrics(&mut document.theme, zoom); let source_default = document.theme.default_text_color; document.theme.default_text_color = foreground; document.theme.document_background_color = transparent_black(); @@ -33,6 +34,19 @@ pub(super) fn apply_flow_cell_theme(document: &mut Document, client_theme: &Docu } } +fn scale_flow_layout_metrics(theme: &mut DocumentTheme, zoom: f32) { + theme.paragraph_after *= zoom; + for style in theme.custom_paragraph_styles.values_mut() { + style.spacing_before *= zoom; + style.spacing_after *= zoom; + if let Some(border) = style.border.as_mut() { + border.width *= zoom; + border.space_x *= zoom; + border.space_y *= zoom; + } + } +} + fn transform_color(source: Hsla, source_default: Hsla, target_default: Hsla, background: Hsla, enforce_text_contrast: bool) -> Hsla { let source = to_oklch(source); let source_default = to_oklch(source_default); @@ -116,7 +130,19 @@ fn ensure_contrast(color: Hsla, background: Hsla, minimum_ratio: f32) -> Hsla { #[cfg(test)] mod tests { use super::*; - use gpui::rgba; + use gpui::{px, rgba}; + + #[test] + fn flow_layout_metrics_scale_with_board_zoom() { + let mut theme = DocumentTheme { + paragraph_after: px(12.0), + ..DocumentTheme::default() + }; + + scale_flow_layout_metrics(&mut theme, 0.25); + + assert_eq!(theme.paragraph_after, px(3.0)); + } #[test] fn transformed_style_colors_preserve_alpha() { diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index c2bb8962..f70c005a 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,12 +1,10 @@ use std::path::PathBuf; use std::collections::HashSet; -use flowstate_flow::{ - AnnotationOriginator, BoardPoint, BoardRect, CellId, FlowDocument, RelativePosition, SheetId, VersionVector, -}; +use flowstate_flow::{AnnotationOriginator, BoardPoint, CellId, FlowDocument, RelativePosition, SheetId, VersionVector}; use gpui::{ AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, - KeyDownEvent, KeyUpEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Render, SharedString, Subscription, Task, Window, canvas, div, point, + KeyDownEvent, KeyUpEvent, MouseMoveEvent, MouseUpEvent, Render, SharedString, Subscription, Task, Window, canvas, div, point, prelude::*, px, rgba, ScrollHandle, ScrollWheelEvent, }; use gpui_component::{Icon, IconName, Sizable as _}; @@ -23,11 +21,16 @@ use crate::{ mod annotation; mod cell_editing; +mod connector; mod drag_drop; mod layout; +mod zoom; +use annotation::paint_stroke; +use connector::paint_connector_family; use drag_drop::{CellDropDestination, FlowCellDrag, FlowCellDragPreview}; -use layout::sheet_cell_layout; +use layout::{CellMeasurement, sheet_cell_layout}; +use zoom::grid_dot_metrics; #[derive(Clone, Debug)] pub enum FlowEditorEvent { @@ -68,11 +71,11 @@ pub struct FlowEditor { cell_editor_subscriptions: std::collections::HashMap, pending_cell_drop: Option, cell_bounds: std::collections::HashMap>, + cell_measurements: std::collections::HashMap, board_scroll: ScrollHandle, board_zoom: f32, camera_center: Option, camera_apply_pending: bool, - board_content_width: gpui::Pixels, viewport_origin: BoardPoint, space_pan_armed: bool, pan_drag: Option, @@ -107,11 +110,11 @@ impl FlowEditor { cell_editor_subscriptions: std::collections::HashMap::new(), pending_cell_drop: None, cell_bounds: std::collections::HashMap::new(), + cell_measurements: std::collections::HashMap::new(), board_scroll: ScrollHandle::new(), board_zoom: 1.0, camera_center: None, camera_apply_pending: false, - board_content_width: px(0.0), viewport_origin: BoardPoint::default(), space_pan_armed: false, pan_drag: None, @@ -168,94 +171,6 @@ impl FlowEditor { self.active_sheet.is_some_and(|sheet| !self.hidden_annotation_sheets.contains(&sheet)) } - pub fn board_zoom(&self) -> f32 { - self.board_zoom - } - - pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut Context) { - let zoom = zoom.clamp(0.25, 4.0); - if (self.board_zoom - zoom).abs() < f32::EPSILON { - return; - } - let viewport = self.board_scroll.bounds().size; - let offset = self.board_scroll.offset(); - let center = self.camera_center.unwrap_or(BoardPoint { - x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / self.board_zoom, - y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / self.board_zoom, - }); - self.board_zoom = zoom; - self.camera_center = Some(center); - self.camera_apply_pending = true; - cx.notify(); - } - - fn apply_camera_center(&mut self) { - if !self.camera_apply_pending { - self.sync_camera_center_from_scroll(); - return; - } - let Some(center) = self.camera_center else { - return; - }; - let viewport = self.board_scroll.bounds().size; - let max_offset = self.board_scroll.max_offset(); - let max_x = (self.board_content_width - viewport.width).max(px(0.0)); - let x = px(-(center.x * self.board_zoom - viewport.width.as_f32() / 2.0)) - .min(px(0.0)) - .max(-max_x); - let y = px(-(center.y * self.board_zoom - viewport.height.as_f32() / 2.0)) - .min(px(0.0)) - .max(-max_offset.height); - if self.board_scroll.offset() != point(x, y) { - self.board_scroll.set_offset(point(x, y)); - } - self.camera_apply_pending = false; - self.sync_camera_center_from_scroll(); - self.camera_center = None; - } - - fn sync_camera_center_from_scroll(&mut self) { - let viewport = self.board_scroll.bounds().size; - let offset = self.board_scroll.offset(); - self.camera_center = Some(BoardPoint { - x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / self.board_zoom, - y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / self.board_zoom, - }); - } - - pub fn zoom_percent(&self) -> f32 { - self.board_zoom * 100.0 - } - - pub fn set_zoom_percent(&mut self, percent: f32, cx: &mut Context) { - self.set_board_zoom(percent / 100.0, cx); - } - - pub fn zoom_in(&mut self, cx: &mut Context) { - self.set_zoom_percent(self.zoom_percent() + 10.0, cx); - } - - pub fn zoom_out(&mut self, cx: &mut Context) { - self.set_zoom_percent(self.zoom_percent() - 10.0, cx); - } - - pub fn visible_board_rect(&self) -> BoardRect { - let bounds = self.board_scroll.bounds(); - let offset = self.board_scroll.offset(); - let scroll_x = -offset.x.as_f32(); - let scroll_y = -offset.y.as_f32(); - BoardRect { - min: BoardPoint { - x: scroll_x, - y: scroll_y, - }, - max: BoardPoint { - x: scroll_x + bounds.size.width.as_f32() / self.board_zoom, - y: scroll_y + bounds.size.height.as_f32() / self.board_zoom, - }, - } - } - pub fn document_path(&self) -> Option<&PathBuf> { self.path.as_ref() } @@ -423,14 +338,21 @@ impl FlowEditor { } fn set_cell_bounds(&mut self, cell_id: CellId, bounds: Bounds, cx: &mut Context) { - let size_changed = self.cell_bounds.get(&cell_id).is_none_or(|previous| previous.size != bounds.size); self.cell_bounds.insert(cell_id, bounds); - if size_changed { + let screen_height = bounds.size.height.as_f32(); + let model_height_changed = match self.cell_measurements.entry(cell_id) { + std::collections::hash_map::Entry::Occupied(mut entry) => entry.get_mut().update(screen_height, self.board_zoom), + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(CellMeasurement::new(screen_height, self.board_zoom)); + true + }, + }; + if model_height_changed { cx.notify(); } } - fn scroll_cell_into_view(&self, cell_id: CellId) { + fn scroll_cell_into_view(&mut self, cell_id: CellId) { let Some(cell) = self.cell_bounds.get(&cell_id) else { return; }; @@ -447,13 +369,7 @@ impl FlowEditor { offset.y -= cell.bottom() - viewport.bottom(); } self.board_scroll.set_offset(offset); - } - - fn set_viewport_origin(&mut self, origin: gpui::Point) { - self.viewport_origin = BoardPoint { - x: origin.x.as_f32(), - y: origin.y.as_f32(), - }; + self.sync_camera_center_from_scroll(); } fn cell_text_color(&self, cell_id: CellId, cx: &App) -> gpui::Hsla { @@ -662,9 +578,8 @@ impl FlowEditor { let zoom = self.board_zoom; let control_size = px(20.0 * zoom); let control_icon_size = px(12.0 * zoom); - let cell_layout = sheet_cell_layout(sheet, &self.cell_bounds, zoom); + let cell_layout = sheet_cell_layout(sheet, &self.cell_measurements, zoom); let board_width = px((32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0) * zoom); - let measured_board_width = board_width; let weak_editor = cx.entity().downgrade(); let weak_connector_editor = weak_editor.clone(); let mut children_by_parent: std::collections::HashMap> = std::collections::HashMap::new(); @@ -679,15 +594,6 @@ impl FlowEditor { .filter_map(|parent| children_by_parent.remove(&parent.id).map(|children| (parent.id, children))) .collect::>(); div() - .on_children_prepainted({ - let weak_editor = weak_editor.clone(); - move |_, _, cx| { - let _ = weak_editor.update(cx, |editor, _| { - editor.board_content_width = measured_board_width; - editor.apply_camera_center(); - }); - } - }) .id("flow-columns") .relative() .min_w_full() @@ -717,7 +623,7 @@ impl FlowEditor { .font_weight(gpui::FontWeight::BOLD) .text_size(px(14.0 * zoom)) .text_color(side_color) - .border_b_2() + .border_b(px(2.0 * zoom)) .border_color(side_color) .child(column.label.clone()) .child( @@ -797,7 +703,7 @@ impl FlowEditor { .min_h(px(54.0 * zoom)) .p(px(10.0 * zoom)) .rounded(px(6.0 * zoom)) - .border_1() + .border(px(1.0 * zoom)) .border_color(if active == Some(id) { side_palette.active } else { @@ -838,7 +744,7 @@ impl FlowEditor { editor.finish_cell_drop(drag.cell_id, cx); cx.stop_propagation(); })) - .when(is_drop_target, |this| this.border_2().border_color(side_palette.active)) + .when(is_drop_target, |this| this.border(px(2.0 * zoom)).border_color(side_palette.active)) .child( div() .id(("flow-cell-drag-handle", id.as_u128() as u64)) @@ -895,43 +801,11 @@ impl FlowEditor { let Some(parent) = connector_bounds.get(parent_id) else { continue; }; - let children = child_ids.iter().filter_map(|id| connector_bounds.get(id)).collect::>(); - let Some(first_child) = children.first() else { - continue; - }; - let scale = window.scale_factor(); - let snap = |value: gpui::Pixels| px(((value.as_f32() * scale).floor() + 0.5) / scale); - let start = point(snap(parent.right()), snap(parent.center().y)); - let midpoint = snap(start.x + (first_child.left() - start.x) / 2.0); - let first_left = snap(first_child.left()); - if children.len() == 1 { - let mut path = PathBuilder::stroke(px(1.0)); - path.move_to(start); - path.line_to(point(first_left, start.y)); - if let Ok(path) = path.build() { - window.paint_path(path, cx.theme().foreground); - } - continue; - } - let lowest_y = children.iter().skip(1).map(|child| snap(child.center().y)).max().unwrap_or(start.y); - let radius = cx.theme().radius.min(px(10.0)).min((lowest_y - start.y).abs()).min((first_child.left() - midpoint).abs()); - let mut path = PathBuilder::stroke(px(1.0)); - path.move_to(start); - path.line_to(point(first_left, start.y)); - path.move_to(point(midpoint, start.y)); - path.line_to(point(midpoint, lowest_y - radius)); - path.curve_to(point(midpoint + radius, lowest_y), point(midpoint, lowest_y)); - path.line_to(point(snap(children.last().unwrap().left()), lowest_y)); - for child in children.iter().skip(1) { - let child_y = snap(child.center().y); - if child_y != lowest_y { - path.move_to(point(midpoint, child_y)); - path.line_to(point(snap(child.left()), child_y)); - } - } - if let Ok(path) = path.build() { - window.paint_path(path, cx.theme().foreground); - } + let children = child_ids + .iter() + .filter_map(|id| connector_bounds.get(id).copied()) + .collect::>(); + paint_connector_family(*parent, &children, cx.theme().foreground, window); } }, ) @@ -971,27 +845,6 @@ impl FlowEditor { } } -fn paint_stroke( - origin: gpui::Point, - points: &[BoardPoint], - width: gpui::Pixels, - color: gpui::Hsla, - zoom: f32, - window: &mut Window, -) { - let Some(first) = points.first() else { - return; - }; - let mut path = PathBuilder::stroke(width); - path.move_to(point(origin.x + px(first.x * zoom), origin.y + px(first.y * zoom))); - for point_value in &points[1..] { - path.line_to(point(origin.x + px(point_value.x * zoom), origin.y + px(point_value.y * zoom))); - } - if let Ok(path) = path.build() { - window.paint_path(path, color); - } -} - impl EventEmitter for FlowEditor {} impl Focusable for FlowEditor { @@ -1002,6 +855,7 @@ impl Focusable for FlowEditor { impl Render for FlowEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.apply_pending_camera_center(); self.refresh_active_cell_theme(cx); if self.pan_drag.is_some() { let editor = cx.entity(); @@ -1045,15 +899,17 @@ impl Render for FlowEditor { |_, _, _| {}, move |bounds, _, window, cx| { let spacing = px(24.0 * board_zoom); - let dot_size = px(1.5 * board_zoom); + let scale = window.scale_factor(); + let (dot_size, dot_opacity) = grid_dot_metrics(board_zoom, scale); let offset = grid_scroll.offset(); let mut x = offset.x % spacing; - let color = cx.theme().border.opacity(0.56); + let color = cx.theme().border.opacity(0.56 * dot_opacity); + let snap = |value: gpui::Pixels| px((value.as_f32() * scale).round() / scale); while x < bounds.size.width { let mut y = offset.y % spacing; while y < bounds.size.height { window.paint_quad(gpui::fill( - gpui::Bounds::new(bounds.origin + point(x, y), gpui::size(dot_size, dot_size)), + gpui::Bounds::new(bounds.origin + point(snap(x), snap(y)), gpui::size(dot_size, dot_size)), color, )); y += spacing; diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs index db1e701b..25992b97 100644 --- a/crates/flowstate/src/flow/editor/annotation.rs +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -1,5 +1,5 @@ use flowstate_flow::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, StrokeStyle}; -use gpui::{Context, Pixels, Point}; +use gpui::{Context, Hsla, PathBuilder, Pixels, Point, Window, point, px}; use gpui_component::PixelsExt as _; use super::{AnnotationTool, FlowEditor}; @@ -51,6 +51,13 @@ impl FlowEditor { } } + pub(super) fn set_viewport_origin(&mut self, origin: Point) { + self.viewport_origin = BoardPoint { + x: origin.x.as_f32(), + y: origin.y.as_f32(), + }; + } + pub(super) fn begin_annotation(&mut self, position: Point, cx: &mut Context) { match self.annotation_tool { AnnotationTool::Marker => { @@ -193,3 +200,17 @@ fn segment_distance(point: BoardPoint, start: BoardPoint, end: BoardPoint) -> f3 let nearest_y = start.y + t * dy; (point.x - nearest_x).hypot(point.y - nearest_y) } + +pub(super) fn paint_stroke(origin: Point, points: &[BoardPoint], width: Pixels, color: Hsla, zoom: f32, window: &mut Window) { + let Some(first) = points.first() else { + return; + }; + let mut path = PathBuilder::stroke(width); + path.move_to(point(origin.x + px(first.x * zoom), origin.y + px(first.y * zoom))); + for point_value in &points[1..] { + path.line_to(point(origin.x + px(point_value.x * zoom), origin.y + px(point_value.y * zoom))); + } + if let Ok(path) = path.build() { + window.paint_path(path, color); + } +} diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs index 28993c55..445c22c2 100644 --- a/crates/flowstate/src/flow/editor/cell_editing.rs +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -88,6 +88,8 @@ impl FlowEditor { self.cell_editors.retain(|id, _| cells.contains_key(id)); self.cell_editor_themes.retain(|id, _| cells.contains_key(id)); self.cell_editor_subscriptions.retain(|id, _| cells.contains_key(id)); + self.cell_bounds.retain(|id, _| cells.contains_key(id)); + self.cell_measurements.retain(|id, _| cells.contains_key(id)); for (cell_id, editor) in &self.cell_editors { if let Some(document) = cells.get(cell_id) { let current = flowstate_document::db8_bytes(editor.read(cx).document()).ok(); diff --git a/crates/flowstate/src/flow/editor/connector.rs b/crates/flowstate/src/flow/editor/connector.rs new file mode 100644 index 00000000..62fe859e --- /dev/null +++ b/crates/flowstate/src/flow/editor/connector.rs @@ -0,0 +1,77 @@ +use gpui::{Bounds, Hsla, PathBuilder, Pixels, Point, Window, point, px}; +use gpui_component::PixelsExt as _; + +#[derive(Debug, PartialEq)] +struct ConnectorGeometry { + start: Point, + midpoint_x: Pixels, + children: Vec>, +} + +pub(super) fn paint_connector_family(parent: Bounds, children: &[Bounds], color: Hsla, window: &mut Window) { + let Some(geometry) = connector_geometry(parent, children, window.scale_factor()) else { + return; + }; + let mut path = PathBuilder::stroke(px(1.0)); + path.move_to(geometry.start); + path.line_to(point(geometry.midpoint_x, geometry.start.y)); + + let min_y = geometry + .children + .iter() + .map(|child| child.y) + .fold(geometry.start.y, Pixels::min); + let max_y = geometry + .children + .iter() + .map(|child| child.y) + .fold(geometry.start.y, Pixels::max); + if min_y != max_y { + path.move_to(point(geometry.midpoint_x, min_y)); + path.line_to(point(geometry.midpoint_x, max_y)); + } + for child in geometry.children { + path.move_to(point(geometry.midpoint_x, child.y)); + path.line_to(child); + } + if let Ok(path) = path.build() { + window.paint_path(path, color); + } +} + +fn connector_geometry(parent: Bounds, children: &[Bounds], device_scale: f32) -> Option { + let first_child = children.first()?; + let snap = |value: Pixels| px(((value.as_f32() * device_scale).floor() + 0.5) / device_scale); + let start = point(snap(parent.right()), snap(parent.center().y)); + let midpoint_x = snap(start.x + (first_child.left() - start.x) / 2.0); + let children = children + .iter() + .map(|child| point(snap(child.left()), snap(child.center().y))) + .collect(); + Some(ConnectorGeometry { + start, + midpoint_x, + children, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Bounds, size}; + + #[test] + fn connector_targets_each_child_midpoint() { + let parent = Bounds::new(point(px(0.0), px(10.0)), size(px(100.0), px(40.0))); + let children = [ + Bounds::new(point(px(200.0), px(10.0)), size(px(100.0), px(80.0))), + Bounds::new(point(px(200.0), px(110.0)), size(px(100.0), px(40.0))), + ]; + + let geometry = connector_geometry(parent, &children, 1.0).unwrap(); + + assert_eq!(geometry.start.y, px(30.5)); + assert_eq!(geometry.children[0].y, px(50.5)); + assert_eq!(geometry.children[1].y, px(130.5)); + } +} diff --git a/crates/flowstate/src/flow/editor/layout.rs b/crates/flowstate/src/flow/editor/layout.rs index 20955be4..fdcfe635 100644 --- a/crates/flowstate/src/flow/editor/layout.rs +++ b/crates/flowstate/src/flow/editor/layout.rs @@ -1,18 +1,47 @@ use std::collections::HashMap; use flowstate_flow::{Cell, CellId, Sheet}; -use gpui::{Bounds, Pixels}; -use gpui_component::PixelsExt as _; pub(super) const CELL_GAP: f32 = 16.0; +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct CellMeasurement { + pub model_height: f32, + screen_height: f32, + zoom: f32, +} + +impl CellMeasurement { + pub fn new(screen_height: f32, zoom: f32) -> Self { + Self { + model_height: screen_height / zoom.max(0.01), + screen_height, + zoom, + } + } + + pub fn update(&mut self, screen_height: f32, zoom: f32) -> bool { + if (self.zoom - zoom).abs() > f32::EPSILON { + self.screen_height = screen_height; + self.zoom = zoom; + return false; + } + if (self.screen_height - screen_height).abs() <= 0.5 { + return false; + } + self.screen_height = screen_height; + self.model_height = screen_height / zoom.max(0.01); + true + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct CellLayout { pub top: f32, pub height: f32, } -pub(super) fn sheet_cell_layout(sheet: &Sheet, bounds: &HashMap>, zoom: f32) -> HashMap { +pub(super) fn sheet_cell_layout(sheet: &Sheet, measurements: &HashMap, zoom: f32) -> HashMap { let cells = sheet.cells.iter().map(|cell| (cell.id, cell)).collect::>(); let mut children: HashMap> = HashMap::new(); for cell in &sheet.cells { @@ -23,7 +52,7 @@ pub(super) fn sheet_cell_layout(sheet: &Sheet, bounds: &HashMap, children: &HashMap>, - bounds: &HashMap>, + measurements: &HashMap, zoom: f32, layout: &mut HashMap, ) -> f32 { let Some(cell) = cells.get(&cell_id) else { return 0.0; }; - let cell_height = measured_cell_height(cell, bounds); + let cell_height = measured_cell_height(cell, measurements) * zoom; layout.insert(cell_id, CellLayout { top, height: cell_height }); let mut children_top = top; let mut children_height = 0.0; for child_id in children.get(&cell_id).into_iter().flatten() { - let child_height = layout_family(*child_id, children_top, cells, children, bounds, zoom, layout); + let child_height = layout_family(*child_id, children_top, cells, children, measurements, zoom, layout); children_top += child_height + CELL_GAP * zoom; children_height += child_height + CELL_GAP * zoom; } @@ -57,8 +86,11 @@ fn layout_family( cell_height.max(children_height) } -fn measured_cell_height(cell: &Cell, bounds: &HashMap>) -> f32 { - bounds.get(&cell.id).map_or_else(|| estimated_cell_height(cell), |bounds| bounds.size.height.as_f32()) +fn measured_cell_height(cell: &Cell, measurements: &HashMap) -> f32 { + measurements + .get(&cell.id) + .map(|measurement| measurement.model_height) + .unwrap_or_else(|| estimated_cell_height(cell)) } fn estimated_cell_height(cell: &Cell) -> f32 { @@ -101,4 +133,46 @@ mod tests { assert_eq!(layout[&root.id].top, 0.0); assert_eq!(layout[&orphan.id].top, 2.0 * 54.0 + 2.0 * CELL_GAP); } + + #[test] + fn layout_scales_homogeneously_across_zoom_levels() { + let root = cell(None); + let child = cell(Some(root.id)); + let sheet = Sheet { + id: flowstate_flow::SheetId::new_v4(), + sheet_type_id: flowstate_flow::SheetTypeId::new_v4(), + name: String::new(), + cells: vec![root.clone(), child.clone()], + annotations: Vec::new(), + }; + let heights = HashMap::from([ + (root.id, CellMeasurement::new(80.0, 1.0)), + (child.id, CellMeasurement::new(120.0, 1.0)), + ]); + + let small = sheet_cell_layout(&sheet, &heights, 0.25); + let large = sheet_cell_layout(&sheet, &heights, 4.0); + + assert_eq!(large[&root.id].height, small[&root.id].height * 16.0); + assert_eq!(large[&child.id].top, small[&child.id].top * 16.0); + assert_eq!(large[&child.id].height, small[&child.id].height * 16.0); + } + + #[test] + fn zoom_measurements_do_not_redefine_intrinsic_cell_height() { + let mut measurement = CellMeasurement::new(80.0, 1.0); + + assert!(!measurement.update(24.0, 0.25)); + assert_eq!(measurement.model_height, 80.0); + assert!(!measurement.update(322.0, 4.0)); + assert_eq!(measurement.model_height, 80.0); + } + + #[test] + fn same_zoom_content_changes_update_intrinsic_cell_height() { + let mut measurement = CellMeasurement::new(80.0, 1.0); + + assert!(measurement.update(120.0, 1.0)); + assert_eq!(measurement.model_height, 120.0); + } } diff --git a/crates/flowstate/src/flow/editor/zoom.rs b/crates/flowstate/src/flow/editor/zoom.rs new file mode 100644 index 00000000..87402abc --- /dev/null +++ b/crates/flowstate/src/flow/editor/zoom.rs @@ -0,0 +1,142 @@ +use flowstate_flow::{BoardPoint, BoardRect}; +use gpui::{Pixels, Point, Size, point, px}; +use gpui_component::PixelsExt as _; + +use super::FlowEditor; + +const MIN_ZOOM_PERCENT: f32 = 25.0; +const MAX_ZOOM_PERCENT: f32 = 400.0; +const ZOOM_STEP_PERCENT: f32 = 5.0; + +impl FlowEditor { + pub fn board_zoom(&self) -> f32 { + self.board_zoom + } + + pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut gpui::Context) { + let percent = ((zoom * 100.0 / ZOOM_STEP_PERCENT).round() * ZOOM_STEP_PERCENT) + .clamp(MIN_ZOOM_PERCENT, MAX_ZOOM_PERCENT); + let zoom = percent / 100.0; + if (self.board_zoom - zoom).abs() < f32::EPSILON { + return; + } + if self.camera_center.is_none() { + self.sync_camera_center_from_scroll(); + } + self.board_zoom = zoom; + self.camera_apply_pending = true; + cx.notify(); + } + + pub(super) fn apply_pending_camera_center(&mut self) { + if !self.camera_apply_pending { + return; + } + if let Some(center) = self.camera_center { + let offset = offset_for_camera_center(center, self.board_scroll.bounds().size, self.board_zoom); + self.board_scroll.set_offset(offset); + } + self.camera_apply_pending = false; + } + + pub(super) fn sync_camera_center_from_scroll(&mut self) { + let viewport = self.board_scroll.bounds().size; + if viewport.width <= px(1.0) || viewport.height <= px(1.0) { + self.camera_center = None; + return; + } + self.camera_center = Some(camera_center_for_offset( + self.board_scroll.offset(), + viewport, + self.board_zoom, + )); + } + + pub fn zoom_percent(&self) -> f32 { + self.board_zoom * 100.0 + } + + pub fn set_zoom_percent(&mut self, percent: f32, cx: &mut gpui::Context) { + self.set_board_zoom(percent / 100.0, cx); + } + + pub fn zoom_in(&mut self, cx: &mut gpui::Context) { + self.set_zoom_percent(self.zoom_percent() + ZOOM_STEP_PERCENT, cx); + } + + pub fn zoom_out(&mut self, cx: &mut gpui::Context) { + self.set_zoom_percent(self.zoom_percent() - ZOOM_STEP_PERCENT, cx); + } + + pub fn visible_board_rect(&self) -> BoardRect { + let bounds = self.board_scroll.bounds(); + let offset = self.board_scroll.offset(); + let scroll_x = -offset.x.as_f32() / self.board_zoom; + let scroll_y = -offset.y.as_f32() / self.board_zoom; + BoardRect { + min: BoardPoint { + x: scroll_x, + y: scroll_y, + }, + max: BoardPoint { + x: scroll_x + bounds.size.width.as_f32() / self.board_zoom, + y: scroll_y + bounds.size.height.as_f32() / self.board_zoom, + }, + } + } +} + +fn camera_center_for_offset(offset: Point, viewport: Size, zoom: f32) -> BoardPoint { + BoardPoint { + x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / zoom, + y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / zoom, + } +} + +fn offset_for_camera_center(center: BoardPoint, viewport: Size, zoom: f32) -> Point { + point( + px(-(center.x * zoom - viewport.width.as_f32() / 2.0)), + px(-(center.y * zoom - viewport.height.as_f32() / 2.0)), + ) +} + +pub(super) fn grid_dot_metrics(zoom: f32, device_scale: f32) -> (Pixels, f32) { + let target_size = 1.5 * zoom; + let target_device_size = target_size * device_scale; + if target_device_size < 1.0 { + (px(1.0 / device_scale), target_device_size * target_device_size) + } else { + (px(target_size), 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + #[test] + fn camera_center_round_trips_across_zoom_levels() { + let viewport = size(px(900.0), px(600.0)); + let original = point(px(-1240.0), px(-780.0)); + let center = camera_center_for_offset(original, viewport, 1.0); + + for zoom in [0.25, 2.0, 0.25, 4.0, 1.0] { + let offset = offset_for_camera_center(center, viewport, zoom); + if zoom == 1.0 { + assert_eq!(offset, original); + } + } + } + + #[test] + fn subpixel_grid_dots_fade_instead_of_becoming_heavier() { + let (small_size, small_opacity) = grid_dot_metrics(0.25, 1.0); + let (normal_size, normal_opacity) = grid_dot_metrics(1.0, 1.0); + + assert_eq!(small_size, px(1.0)); + assert!(small_opacity < normal_opacity); + assert_eq!(normal_size, px(1.5)); + assert_eq!(normal_opacity, 1.0); + } +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs index 11a192a1..b0be54ef 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs @@ -18,6 +18,7 @@ impl RichTextEditor { let width = self .measured_item_width .unwrap_or(if has_measured_viewport { viewport_width } else { px(900.0) }); + self.prepare_pending_zoom_anchor(width, window, cx); if has_measured_viewport && self.initial_layout_hidden { self.ensure_exact_initial_viewport_chunks(width, window, cx); } @@ -69,6 +70,7 @@ impl RichTextEditor { window: &mut Window, cx: &mut Context, ) -> Rc>> { + let zoom_anchor = self.active_zoom_anchor(); let old_cache = self.item_sizes_cache.take(); let (items, block_item_ranges, block_heights, sizes) = self.virtual_item_sizes(width, old_cache, window, cx); let (paragraph_chunk_item_ranges, paragraph_remainder_items) = item_lookup_for_virtual_items(items.as_ref(), self.document.paragraphs.len()); @@ -89,7 +91,7 @@ impl RichTextEditor { sizes: sizes.clone(), }); self.restore_scroll_anchor(scroll_anchor); - self.apply_pending_zoom_center(); + self.restore_preserved_zoom_anchor(zoom_anchor); if schedule_prefetch { self.schedule_chunk_prefetch(width, window, cx); } @@ -103,6 +105,7 @@ impl RichTextEditor { window: &mut Window, cx: &mut Context, ) -> Option>>> { + let zoom_anchor = self.active_zoom_anchor(); let range = self.pending_item_sizes_patch_range.clone()?; let paragraph_count = self.document.paragraphs.len(); if range.start > range.end || range.end > paragraph_count || self.document_has_object_blocks() { @@ -181,7 +184,7 @@ impl RichTextEditor { } self.pending_item_sizes_patch_range = None; self.restore_scroll_anchor(scroll_anchor); - self.apply_pending_zoom_center(); + self.restore_preserved_zoom_anchor(zoom_anchor); self.schedule_chunk_prefetch(width, window, cx); Some(patched_sizes) } diff --git a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs index bc5d7880..6ec77d95 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs @@ -422,6 +422,7 @@ impl RichTextEditor { fn prepare_render_layout(&mut self, window: &mut Window, cx: &mut Context) -> RenderLayoutSnapshot { let hide_until_viewport_measured = self.scroll_handle.bounds().size.width <= px(1.0); let mut item_sizes = self.paragraph_item_sizes(window, cx); + self.apply_pending_zoom_anchor(); let has_startup_layout_width = self.measured_item_width.is_some() || self.document.paragraphs.is_empty(); if !hide_until_viewport_measured && self.initial_layout_hidden && has_startup_layout_width { self.initial_layout_hidden = false; @@ -440,6 +441,7 @@ impl RichTextEditor { && let Some(cache) = &self.item_sizes_cache { item_sizes = cache.sizes.clone(); + self.apply_pending_zoom_anchor(); } let base_items = self .item_sizes_cache diff --git a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs index 208efae9..d376a7b0 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs @@ -33,7 +33,7 @@ impl RichTextEditor { last_send_document_generation: None, last_format_export_generation: None, zoom_percent: 100.0, - zoom_scroll_anchor: None, + zoom_anchor: None, zoom_anchor_apply_pending: false, save_status: SaveStatus::Saved, undo_stack: Vec::new(), @@ -137,6 +137,8 @@ impl RichTextEditor { self.last_send_document_generation = None; self.last_format_export_generation = None; self.zoom_percent = 100.0; + self.zoom_anchor = None; + self.zoom_anchor_apply_pending = false; self.collapsed_section_ids.clear(); self.hovered_collapse_paragraph = None; self.document.theme.zoom_factor = 1.0; diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index f28bcc16..d073f336 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -873,7 +873,7 @@ pub struct RichTextEditor { last_send_document_generation: Option, last_format_export_generation: Option, zoom_percent: f32, - zoom_scroll_anchor: Option, + zoom_anchor: Option, zoom_anchor_apply_pending: bool, save_status: SaveStatus, undo_stack: Vec, diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index 013b14e3..4b980009 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -121,9 +121,8 @@ impl Render for RichTextEditor { .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_scroll_wheel(cx.listener(|editor, event: &gpui::ScrollWheelEvent, window, cx| { if !event.modifiers.control { - editor.zoom_scroll_anchor = None; - editor.zoom_anchor_apply_pending = false; - cx.on_next_frame(window, |editor, _, _| editor.sync_camera_center_from_scroll()); + editor.reset_zoom_anchor(); + cx.on_next_frame(window, |editor, _, _| editor.reset_zoom_anchor()); } })) .drag_over::(|style, _, _, cx| style.border_1().border_color(cx.theme().drag_border)) diff --git a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs index 903fdd11..5542f805 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs @@ -2,6 +2,30 @@ const MIN_ZOOM_PERCENT: f32 = 25.0; const MAX_ZOOM_PERCENT: f32 = 400.0; const ZOOM_STEP_PERCENT: f32 = 5.0; +#[derive(Clone, Copy)] +struct ZoomAnchorSnapshot { + target: ZoomAnchorTarget, + viewport_y_ratio: f32, + scroll_y: Pixels, + edit_generation: u64, + invisibility_mode: bool, +} + +#[derive(Clone, Copy)] +enum ZoomAnchorTarget { + Text { + offset: DocumentOffset, + line_y_ratio: f32, + }, + Block { + block_ix: usize, + block_y_ratio: f32, + }, + Document { + content_y_ratio: f32, + }, +} + #[hotpath::measure_all] impl RichTextEditor { pub fn zoom_percent(&self) -> f32 { @@ -13,8 +37,8 @@ impl RichTextEditor { if (self.zoom_percent - percent).abs() < f32::EPSILON { return; } - if self.zoom_scroll_anchor.is_none() { - self.zoom_scroll_anchor = self.capture_scroll_anchor(); + if self.zoom_anchor.is_none_or(|anchor| !self.zoom_anchor_is_current(anchor)) { + self.zoom_anchor = self.capture_zoom_anchor(); } self.zoom_percent = percent; self.document.theme.zoom_factor = percent / 100.0; @@ -23,20 +47,176 @@ impl RichTextEditor { cx.notify(); } - pub(super) fn apply_pending_zoom_center(&mut self) { + pub(super) fn prepare_pending_zoom_anchor(&mut self, width: Pixels, window: &mut Window, cx: &mut Context) { if !self.zoom_anchor_apply_pending { return; } - self.restore_scroll_anchor(self.zoom_scroll_anchor.clone()); - self.zoom_anchor_apply_pending = false; - self.zoom_scroll_anchor = None; + let Some(ZoomAnchorSnapshot { + target: ZoomAnchorTarget::Text { offset, .. }, + .. + }) = self.zoom_anchor + else { + return; + }; + let _ = self.ensure_paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width, window, cx); + } + + pub(super) fn apply_pending_zoom_anchor(&mut self) { + if !self.zoom_anchor_apply_pending { + return; + } + let Some(anchor) = self.zoom_anchor else { + self.zoom_anchor_apply_pending = false; + return; + }; + self.restore_preserved_zoom_anchor(Some(anchor)); } - pub(super) fn sync_camera_center_from_scroll(&mut self) { - self.zoom_scroll_anchor = None; + pub(super) fn reset_zoom_anchor(&mut self) { + self.zoom_anchor = None; self.zoom_anchor_apply_pending = false; } + fn active_zoom_anchor(&self) -> Option { + self.zoom_anchor.filter(|anchor| self.zoom_anchor_is_current(*anchor)) + } + + fn restore_preserved_zoom_anchor(&mut self, anchor: Option) { + let Some(mut anchor) = anchor else { + return; + }; + if self.restore_zoom_anchor(anchor) { + anchor.scroll_y = self.scroll_handle.offset().y; + self.zoom_anchor = Some(anchor); + self.zoom_anchor_apply_pending = false; + } + } + + fn zoom_anchor_is_current(&self, anchor: ZoomAnchorSnapshot) -> bool { + anchor.edit_generation == self.edit_generation + && anchor.invisibility_mode == self.invisibility_mode + && (anchor.scroll_y - self.scroll_handle.offset().y).abs() <= px(0.5) + } + + fn capture_zoom_anchor(&self) -> Option { + let viewport = self.scroll_handle.bounds(); + if viewport.size.height <= px(1.0) { + return None; + } + let viewport_y_ratio = 0.5; + let view_y = viewport.size.height * viewport_y_ratio; + let content_y = (-self.scroll_handle.offset().y + view_y).max(px(0.0)); + let valid_cache = self + .item_sizes_cache + .as_ref() + .filter(|cache| cache.item_count > 0 && self.height_prefix_index.len() == cache.item_count); + let total_height = valid_cache + .map_or_else(|| self.scroll_handle.content_size().height, |_| px(self.height_prefix_index.total_height())) + .max(px(1.0)); + let document_target = ZoomAnchorTarget::Document { + content_y_ratio: (content_y / total_height).clamp(0.0, 1.0), + }; + let Some(cache) = valid_cache else { + return Some(ZoomAnchorSnapshot { + target: document_target, + viewport_y_ratio, + scroll_y: self.scroll_handle.offset().y, + edit_generation: self.edit_generation, + invisibility_mode: self.invisibility_mode, + }); + }; + let item_ix = self.height_prefix_index.lower_bound(content_y); + let target = match cache.items.get(item_ix) { + Some(VirtualItem::ParagraphChunk { .. } | VirtualItem::ParagraphRemainder { .. }) => { + let position = point(viewport.left() + viewport.size.width / 2.0, viewport.top() + view_y); + self.hit_test_cached_position(position).map_or(document_target, |offset| ZoomAnchorTarget::Text { + offset, + line_y_ratio: self + .zoom_text_line_y_ratio(offset, content_y) + .unwrap_or(0.5), + }) + }, + Some(VirtualItem::StructuralBlock { block_ix }) => { + let item_top = self.height_prefix_index.item_top(item_ix); + let item_height = cache + .sizes + .get(item_ix) + .map(|size| size.height) + .unwrap_or(px(1.0)) + .max(px(1.0)); + ZoomAnchorTarget::Block { + block_ix: *block_ix, + block_y_ratio: ((content_y - item_top) / item_height).clamp(0.0, 1.0), + } + }, + Some(VirtualItem::HiddenBlock { .. }) | None => document_target, + }; + Some(ZoomAnchorSnapshot { + target, + viewport_y_ratio, + scroll_y: self.scroll_handle.offset().y, + edit_generation: self.edit_generation, + invisibility_mode: self.invisibility_mode, + }) + } + + fn zoom_text_line_y_ratio(&self, offset: DocumentOffset, content_y: Pixels) -> Option { + let width = self.current_layout_width(); + let (chunk_ix, layout) = self.paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width)?; + let item_top = self.item_top_for_paragraph_chunk(offset.paragraph, chunk_ix)?; + let caret = caret_bounds(&layout, offset, point(px(0.0), item_top))?; + Some(relative_line_y(content_y, caret.top(), caret.size.height)) + } + + fn zoom_text_anchor_content_y(&self, offset: DocumentOffset, line_y_ratio: f32) -> Option { + let width = self.current_layout_width(); + let (chunk_ix, layout) = self.paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width)?; + let item_top = self.item_top_for_paragraph_chunk(offset.paragraph, chunk_ix)?; + let caret = caret_bounds(&layout, offset, point(px(0.0), item_top))?; + Some(line_y_from_relative(caret.top(), caret.size.height, line_y_ratio)) + } + + fn restore_zoom_anchor(&mut self, anchor: ZoomAnchorSnapshot) -> bool { + let viewport = self.scroll_handle.bounds(); + if viewport.size.height <= px(1.0) || self.height_prefix_index.len() == 0 { + return false; + } + let total_height = px(self.height_prefix_index.total_height()); + let target_y = match anchor.target { + ZoomAnchorTarget::Text { offset, line_y_ratio } => { + let Some(target_y) = self.zoom_text_anchor_content_y(offset, line_y_ratio) else { + return false; + }; + target_y + }, + ZoomAnchorTarget::Block { + block_ix, + block_y_ratio, + } => { + let Some(cache) = &self.item_sizes_cache else { + return false; + }; + let Some(block_top) = self.block_top_for_index(block_ix) else { + return false; + }; + let Some(block_height) = cache.block_heights.get(block_ix).copied() else { + return false; + }; + block_top + block_height * block_y_ratio + }, + ZoomAnchorTarget::Document { content_y_ratio } => total_height * content_y_ratio, + }; + let mut offset = self.scroll_handle.offset(); + offset.y = -anchored_scroll_top( + target_y, + viewport.size.height * anchor.viewport_y_ratio, + total_height, + viewport.size.height, + ); + self.scroll_handle.set_offset(offset); + true + } + fn zoom_by(&mut self, delta_percent: f32, cx: &mut Context) { self.set_zoom_percent(self.zoom_percent + delta_percent, cx); } @@ -49,3 +229,58 @@ impl RichTextEditor { self.zoom_by(-ZOOM_STEP_PERCENT, cx); } } + +fn anchored_scroll_top(target_y: Pixels, viewport_y: Pixels, total_height: Pixels, viewport_height: Pixels) -> Pixels { + let max_scroll_top = (total_height - viewport_height).max(px(0.0)); + (target_y - viewport_y).max(px(0.0)).min(max_scroll_top) +} + +fn relative_line_y(content_y: Pixels, line_top: Pixels, line_height: Pixels) -> f32 { + (content_y - line_top) / line_height.max(px(1.0)) +} + +fn line_y_from_relative(line_top: Pixels, line_height: Pixels, relative_y: f32) -> Pixels { + line_top + line_height.max(px(1.0)) * relative_y +} + +#[cfg(test)] +mod zoom_tests { + use super::*; + + #[test] + fn semantic_anchor_round_trips_through_short_intermediate_layouts() { + let viewport_height = px(600.0); + let viewport_y = viewport_height / 2.0; + let original_target = px(1500.0); + let original_scroll = anchored_scroll_top(original_target, viewport_y, px(3000.0), viewport_height); + + assert_eq!(original_scroll, px(1200.0)); + assert_eq!( + anchored_scroll_top(px(150.0), viewport_y, px(300.0), viewport_height), + px(0.0) + ); + assert_eq!( + anchored_scroll_top(original_target, viewport_y, px(3000.0), viewport_height), + original_scroll + ); + } + + #[test] + fn anchor_clamps_only_against_the_new_layout_extent() { + assert_eq!( + anchored_scroll_top(px(1900.0), px(300.0), px(2000.0), px(600.0)), + px(1400.0) + ); + } + + #[test] + fn semantic_line_anchor_preserves_positions_outside_the_line_box() { + let line_top = px(100.0); + let line_height = px(20.0); + + for content_y in [px(92.0), px(110.0), px(129.0)] { + let relative_y = relative_line_y(content_y, line_top, line_height); + assert_eq!(line_y_from_relative(line_top, line_height, relative_y), content_y); + } + } +} From a401174162a7173eb2256a9e81cea7fcbe1a03b4 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Mon, 15 Jun 2026 05:55:42 -0500 Subject: [PATCH 10/24] Nukeshot gortnite --- crates/flowstate/src/flow/editor.rs | 20 ++-- crates/flowstate/src/flow/editor/zoom.rs | 103 +++++++++++++++++- .../src/workspace/workspace/theme.rs | 4 - .../src/workspace/workspace/top_bar.rs | 24 +--- .../src/rich_text/editor/zoom.rs | 22 ++++ vendor/gpui-component/src/menu/popup_menu.rs | 16 --- 6 files changed, 135 insertions(+), 54 deletions(-) diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index f70c005a..b0790e0b 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -368,8 +368,7 @@ impl FlowEditor { } else if cell.bottom() > viewport.bottom() { offset.y -= cell.bottom() - viewport.bottom(); } - self.board_scroll.set_offset(offset); - self.sync_camera_center_from_scroll(); + self.set_user_scroll_offset(offset); } fn cell_text_color(&self, cell_id: CellId, cx: &App) -> gpui::Hsla { @@ -407,14 +406,14 @@ impl FlowEditor { } pan_drag.frame_scheduled = true; cx.on_next_frame(window, |editor, _, cx| { - let Some(pan_drag) = editor.pan_drag.as_mut() else { - return; + let offset = { + let Some(pan_drag) = editor.pan_drag.as_mut() else { + return; + }; + pan_drag.frame_scheduled = false; + pan_drag.scroll_anchor + (pan_drag.pending_position - pan_drag.pointer_anchor) }; - pan_drag.frame_scheduled = false; - editor - .board_scroll - .set_offset(pan_drag.scroll_anchor + (pan_drag.pending_position - pan_drag.pointer_anchor)); - editor.sync_camera_center_from_scroll(); + editor.set_user_scroll_offset(offset); cx.notify(); }); } @@ -944,8 +943,7 @@ impl Render for FlowEditor { let delta = event.delta.pixel_delta(window.line_height()); let mut offset = editor.board_scroll.offset(); offset.x += delta.y + delta.x; - editor.board_scroll.set_offset(offset); - editor.sync_camera_center_from_scroll(); + editor.set_user_scroll_offset(offset); cx.stop_propagation(); cx.notify(); } else if !event.modifiers.control { diff --git a/crates/flowstate/src/flow/editor/zoom.rs b/crates/flowstate/src/flow/editor/zoom.rs index 87402abc..f52e9e25 100644 --- a/crates/flowstate/src/flow/editor/zoom.rs +++ b/crates/flowstate/src/flow/editor/zoom.rs @@ -20,7 +20,7 @@ impl FlowEditor { if (self.board_zoom - zoom).abs() < f32::EPSILON { return; } - if self.camera_center.is_none() { + if !self.camera_apply_pending && self.camera_center.is_none_or(|center| !self.camera_center_is_current(center)) { self.sync_camera_center_from_scroll(); } self.board_zoom = zoom; @@ -46,12 +46,34 @@ impl FlowEditor { return; } self.camera_center = Some(camera_center_for_offset( - self.board_scroll.offset(), + clamp_scroll_offset(self.board_scroll.offset(), self.board_scroll.max_offset()), viewport, self.board_zoom, )); } + pub(super) fn set_user_scroll_offset(&mut self, offset: Point) { + self + .board_scroll + .set_offset(clamp_scroll_offset(offset, self.board_scroll.max_offset())); + self.sync_camera_center_from_scroll(); + } + + fn camera_center_is_current(&self, center: BoardPoint) -> bool { + let viewport = self.board_scroll.bounds().size; + if viewport.width <= px(1.0) || viewport.height <= px(1.0) { + return false; + } + camera_center_matches_offset( + center, + self.board_scroll.offset(), + viewport, + self.board_scroll.max_offset(), + self.board_zoom, + px(0.5), + ) + } + pub fn zoom_percent(&self) -> f32 { self.board_zoom * 100.0 } @@ -100,6 +122,28 @@ fn offset_for_camera_center(center: BoardPoint, viewport: Size, zoom: f3 ) } +fn clamp_scroll_offset(mut offset: Point, max_offset: Size) -> Point { + offset.x = offset.x.clamp(-max_offset.width, px(0.0)); + offset.y = offset.y.clamp(-max_offset.height, px(0.0)); + offset +} + +fn points_are_close(left: Point, right: Point, tolerance: Pixels) -> bool { + (left.x - right.x).abs() <= tolerance && (left.y - right.y).abs() <= tolerance +} + +fn camera_center_matches_offset( + center: BoardPoint, + offset: Point, + viewport: Size, + max_offset: Size, + zoom: f32, + tolerance: Pixels, +) -> bool { + let expected = clamp_scroll_offset(offset_for_camera_center(center, viewport, zoom), max_offset); + points_are_close(expected, offset, tolerance) +} + pub(super) fn grid_dot_metrics(zoom: f32, device_scale: f32) -> (Pixels, f32) { let target_size = 1.5 * zoom; let target_device_size = target_size * device_scale; @@ -129,6 +173,61 @@ mod tests { } } + #[test] + fn stale_camera_center_is_detected_after_user_scroll() { + let viewport = size(px(900.0), px(600.0)); + let max_offset = size(px(1800.0), px(1200.0)); + let center = camera_center_for_offset(point(px(-400.0), px(-300.0)), viewport, 1.0); + + assert!(camera_center_matches_offset( + center, + point(px(-400.0), px(-300.0)), + viewport, + max_offset, + 1.0, + px(0.5), + )); + assert!(!camera_center_matches_offset( + center, + point(px(-900.0), px(-300.0)), + viewport, + max_offset, + 1.0, + px(0.5), + )); + } + + #[test] + fn clamped_camera_center_remains_valid_for_round_trip_zoom() { + let viewport = size(px(900.0), px(600.0)); + let center = BoardPoint { x: 1400.0, y: 900.0 }; + + assert!(camera_center_matches_offset( + center, + point(px(0.0), px(0.0)), + viewport, + size(px(0.0), px(0.0)), + 0.25, + px(0.5), + )); + } + + #[test] + fn camera_center_is_invalidated_when_viewport_geometry_changes() { + let original_viewport = size(px(900.0), px(600.0)); + let offset = point(px(-400.0), px(-300.0)); + let center = camera_center_for_offset(offset, original_viewport, 1.0); + + assert!(!camera_center_matches_offset( + center, + offset, + size(px(1100.0), px(600.0)), + size(px(1800.0), px(1200.0)), + 1.0, + px(0.5), + )); + } + #[test] fn subpixel_grid_dots_fade_instead_of_becoming_heavier() { let (small_size, small_opacity) = grid_dot_metrics(0.25, 1.0); diff --git a/crates/flowstate/src/workspace/workspace/theme.rs b/crates/flowstate/src/workspace/workspace/theme.rs index 8a95df72..463a8252 100644 --- a/crates/flowstate/src/workspace/workspace/theme.rs +++ b/crates/flowstate/src/workspace/workspace/theme.rs @@ -10,10 +10,6 @@ fn apply_app_theme_config(theme_name: &str, window: Option<&mut Window>, cx: &mu true } -fn preview_app_theme(theme_name: &str, window: Option<&mut Window>, cx: &mut App) { - let _ = apply_app_theme_config(theme_name, window, cx); -} - fn apply_app_theme(theme_name: &str, window: Option<&mut Window>, cx: &mut App) { if !apply_app_theme_config(theme_name, window, cx) { return; diff --git a/crates/flowstate/src/workspace/workspace/top_bar.rs b/crates/flowstate/src/workspace/workspace/top_bar.rs index 4e102b2a..25a7f1ec 100644 --- a/crates/flowstate/src/workspace/workspace/top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/top_bar.rs @@ -32,32 +32,14 @@ fn flowstate_top_bar_button(cx: &mut Context) -> impl IntoElement { .scrollbar_show(gpui_component::scroll::ScrollbarShow::Always); theme_names.iter().fold(menu, |menu, theme_name| { let selected = theme_name == ¤t_theme; - let label = theme_name.clone(); - let preview_theme = theme_name.clone(); - let restore_theme = current_theme.clone(); let apply_theme = theme_name.clone(); - let committed = Rc::new(Cell::new(false)); menu.item( - PopupMenuItem::new(label) + PopupMenuItem::new(theme_name.clone()) .checked(selected) .keep_open(true) .radio(true) - .on_hover({ - let committed = committed.clone(); - move |hovered, window, cx| { - if *hovered { - preview_app_theme(&preview_theme, Some(window), cx); - } else if !committed.get() { - preview_app_theme(&restore_theme, Some(window), cx); - } - } - }) - .on_click({ - let committed = committed.clone(); - move |_, window, cx| { - committed.set(true); - apply_app_theme(&apply_theme, Some(window), cx); - } + .on_click(move |_, window, cx| { + apply_app_theme(&apply_theme, Some(window), cx); }), ) }) diff --git a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs index 5542f805..e77f1aa7 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs @@ -6,6 +6,7 @@ const ZOOM_STEP_PERCENT: f32 = 5.0; struct ZoomAnchorSnapshot { target: ZoomAnchorTarget, viewport_y_ratio: f32, + viewport_size: Size, scroll_y: Pixels, edit_generation: u64, invisibility_mode: bool, @@ -95,6 +96,7 @@ impl RichTextEditor { fn zoom_anchor_is_current(&self, anchor: ZoomAnchorSnapshot) -> bool { anchor.edit_generation == self.edit_generation && anchor.invisibility_mode == self.invisibility_mode + && sizes_are_close(anchor.viewport_size, self.scroll_handle.bounds().size, px(0.5)) && (anchor.scroll_y - self.scroll_handle.offset().y).abs() <= px(0.5) } @@ -120,6 +122,7 @@ impl RichTextEditor { return Some(ZoomAnchorSnapshot { target: document_target, viewport_y_ratio, + viewport_size: viewport.size, scroll_y: self.scroll_handle.offset().y, edit_generation: self.edit_generation, invisibility_mode: self.invisibility_mode, @@ -154,6 +157,7 @@ impl RichTextEditor { Some(ZoomAnchorSnapshot { target, viewport_y_ratio, + viewport_size: viewport.size, scroll_y: self.scroll_handle.offset().y, edit_generation: self.edit_generation, invisibility_mode: self.invisibility_mode, @@ -243,6 +247,10 @@ fn line_y_from_relative(line_top: Pixels, line_height: Pixels, relative_y: f32) line_top + line_height.max(px(1.0)) * relative_y } +fn sizes_are_close(left: Size, right: Size, tolerance: Pixels) -> bool { + (left.width - right.width).abs() <= tolerance && (left.height - right.height).abs() <= tolerance +} + #[cfg(test)] mod zoom_tests { use super::*; @@ -283,4 +291,18 @@ mod zoom_tests { assert_eq!(line_y_from_relative(line_top, line_height, relative_y), content_y); } } + + #[test] + fn semantic_anchor_is_invalidated_when_viewport_geometry_changes() { + assert!(sizes_are_close( + size(px(900.0), px(600.0)), + size(px(900.25), px(599.75)), + px(0.5), + )); + assert!(!sizes_are_close( + size(px(900.0), px(600.0)), + size(px(1100.0), px(600.0)), + px(0.5), + )); + } } diff --git a/vendor/gpui-component/src/menu/popup_menu.rs b/vendor/gpui-component/src/menu/popup_menu.rs index 779727f1..a7741d8f 100644 --- a/vendor/gpui-component/src/menu/popup_menu.rs +++ b/vendor/gpui-component/src/menu/popup_menu.rs @@ -1078,22 +1078,6 @@ impl PopupMenu { } pub(crate) fn keep_open_confirmed(&mut self, _: &mut Window, cx: &mut Context) { - let any_checked = self.menu_items.iter().any(|i| i.is_checked()); - if let Some(parent_entity) = self.parent_menu.as_ref().and_then(|p| p.upgrade()) { - let current = cx.entity().downgrade(); - parent_entity.update(cx, |parent, cx| { - if let Some(current_entity) = current.upgrade() { - for item in parent.menu_items.iter_mut() { - if let PopupMenuItem::Submenu { checked, menu, .. } = item { - if menu.entity_id() == current_entity.entity_id() { - *checked = any_checked; - } - } - } - } - cx.notify(); - }); - } cx.notify(); } From 614549d48b30e3d6bde23d46317b7354ca700fad Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Mon, 15 Jun 2026 16:35:58 -0500 Subject: [PATCH 11/24] Vendor rimalloc 4 windows --- Cargo.lock | 216 ++- Cargo.toml | 2 +- crates/flowstate/Cargo.toml | 2 +- crates/flowstate/src/main.rs | 10 +- vendor/rimalloc/Cargo.toml | 23 + vendor/rimalloc/examples/global.rs | 37 + vendor/rimalloc/examples/smoke.rs | 102 ++ vendor/rimalloc/examples/stress.rs | 173 ++ vendor/rimalloc/src/alloc.rs | 61 + vendor/rimalloc/src/api.rs | 737 ++++++++ vendor/rimalloc/src/arena.rs | 426 +++++ vendor/rimalloc/src/bins.rs | 136 ++ vendor/rimalloc/src/bitmap.rs | 233 +++ vendor/rimalloc/src/constants.rs | 114 ++ vendor/rimalloc/src/free.rs | 245 +++ vendor/rimalloc/src/heap.rs | 326 ++++ vendor/rimalloc/src/heap_ops.rs | 281 ++++ vendor/rimalloc/src/hooks.rs | 127 ++ vendor/rimalloc/src/init.rs | 624 +++++++ vendor/rimalloc/src/lib.rs | 95 ++ vendor/rimalloc/src/options.rs | 246 +++ vendor/rimalloc/src/os.rs | 842 ++++++++++ vendor/rimalloc/src/page.rs | 410 +++++ vendor/rimalloc/src/page_ops.rs | 610 +++++++ vendor/rimalloc/src/protocol.rs | 217 +++ vendor/rimalloc/src/segment.rs | 1852 +++++++++++++++++++++ vendor/rimalloc/src/stats.rs | 149 ++ vendor/rimalloc/src/sync.rs | 30 + vendor/rimalloc/src/visit.rs | 139 ++ vendor/rimalloc/tests/api.rs | 412 +++++ vendor/rimalloc/tests/api_alignment.rs | 60 + vendor/rimalloc/tests/arena_purge.rs | 41 + vendor/rimalloc/tests/committed_stat.rs | 124 ++ vendor/rimalloc/tests/coverage_gaps.rs | 680 ++++++++ vendor/rimalloc/tests/debug_fill.rs | 35 + vendor/rimalloc/tests/fill.rs | 172 ++ vendor/rimalloc/tests/fork.rs | 72 + vendor/rimalloc/tests/lean_conformance.rs | 94 ++ vendor/rimalloc/tests/loom_arena.rs | 328 ++++ vendor/rimalloc/tests/loom_protocol.rs | 215 +++ vendor/rimalloc/tests/mt_stress.rs | 144 ++ vendor/rimalloc/tests/no_arena_cache.rs | 54 + vendor/rimalloc/tests/options_env.rs | 26 + vendor/rimalloc/tests/parity.rs | 150 ++ vendor/rimalloc/tests/secure.rs | 44 + vendor/rimalloc/tests/target_segments.rs | 61 + 46 files changed, 11053 insertions(+), 124 deletions(-) create mode 100644 vendor/rimalloc/Cargo.toml create mode 100644 vendor/rimalloc/examples/global.rs create mode 100644 vendor/rimalloc/examples/smoke.rs create mode 100644 vendor/rimalloc/examples/stress.rs create mode 100644 vendor/rimalloc/src/alloc.rs create mode 100644 vendor/rimalloc/src/api.rs create mode 100644 vendor/rimalloc/src/arena.rs create mode 100644 vendor/rimalloc/src/bins.rs create mode 100644 vendor/rimalloc/src/bitmap.rs create mode 100644 vendor/rimalloc/src/constants.rs create mode 100644 vendor/rimalloc/src/free.rs create mode 100644 vendor/rimalloc/src/heap.rs create mode 100644 vendor/rimalloc/src/heap_ops.rs create mode 100644 vendor/rimalloc/src/hooks.rs create mode 100644 vendor/rimalloc/src/init.rs create mode 100644 vendor/rimalloc/src/lib.rs create mode 100644 vendor/rimalloc/src/options.rs create mode 100644 vendor/rimalloc/src/os.rs create mode 100644 vendor/rimalloc/src/page.rs create mode 100644 vendor/rimalloc/src/page_ops.rs create mode 100644 vendor/rimalloc/src/protocol.rs create mode 100644 vendor/rimalloc/src/segment.rs create mode 100644 vendor/rimalloc/src/stats.rs create mode 100644 vendor/rimalloc/src/sync.rs create mode 100644 vendor/rimalloc/src/visit.rs create mode 100644 vendor/rimalloc/tests/api.rs create mode 100644 vendor/rimalloc/tests/api_alignment.rs create mode 100644 vendor/rimalloc/tests/arena_purge.rs create mode 100644 vendor/rimalloc/tests/committed_stat.rs create mode 100644 vendor/rimalloc/tests/coverage_gaps.rs create mode 100644 vendor/rimalloc/tests/debug_fill.rs create mode 100644 vendor/rimalloc/tests/fill.rs create mode 100644 vendor/rimalloc/tests/fork.rs create mode 100644 vendor/rimalloc/tests/lean_conformance.rs create mode 100644 vendor/rimalloc/tests/loom_arena.rs create mode 100644 vendor/rimalloc/tests/loom_protocol.rs create mode 100644 vendor/rimalloc/tests/mt_stress.rs create mode 100644 vendor/rimalloc/tests/no_arena_cache.rs create mode 100644 vendor/rimalloc/tests/options_env.rs create mode 100644 vendor/rimalloc/tests/parity.rs create mode 100644 vendor/rimalloc/tests/secure.rs create mode 100644 vendor/rimalloc/tests/target_segments.rs diff --git a/Cargo.lock b/Cargo.lock index 131c2c5f..3ea46054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object 0.37.3", ] @@ -837,9 +837,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", "zeroize", @@ -878,9 +878,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -888,9 +888,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling 0.23.0", "ident_case", @@ -1059,9 +1059,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1777,6 +1777,15 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +dependencies = [ + "linktime-proc-macro", +] + [[package]] name = "ctor-proc-macro" version = "0.0.6" @@ -1885,7 +1894,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1936,7 +1944,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", "ctutils", @@ -2466,7 +2474,7 @@ checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ "bit-set", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -2569,7 +2577,7 @@ dependencies = [ "pathdiff", "rayon", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "serde", "smallvec", "thiserror 2.0.18", @@ -2662,8 +2670,8 @@ dependencies = [ "gpui-symbols", "hotpath", "memchr", - "mimalloc", "palette", + "rimalloc", "rustc-hash 2.1.2", "serde", "serde_json", @@ -3174,7 +3182,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -3269,7 +3277,7 @@ dependencies = [ "core-text", "core-video", "cosmic-text", - "ctor", + "ctor 0.4.3", "derive_more", "embed-resource", "etagere", @@ -3505,7 +3513,7 @@ dependencies = [ "bindgen", "core-foundation 0.10.0", "core-video", - "ctor", + "ctor 0.4.3", "foreign-types", "metal", "objc", @@ -3607,9 +3615,9 @@ checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -4032,9 +4040,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -4751,13 +4759,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -4869,9 +4876,9 @@ dependencies = [ [[package]] name = "leb128" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" [[package]] name = "leb128fmt" @@ -4941,15 +4948,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libmimalloc-sys" -version = "0.1.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" -dependencies = [ - "cc", -] - [[package]] name = "libredox" version = "0.1.17" @@ -4988,6 +4986,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5481,9 +5485,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -5518,15 +5522,6 @@ dependencies = [ "paste", ] -[[package]] -name = "mimalloc" -version = "0.1.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" -dependencies = [ - "libmimalloc-sys", -] - [[package]] name = "mime" version = "0.3.17" @@ -5714,9 +5709,9 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728e0731ad3a0083b9f72a82df72c298641ba970d4e308e0897ec4c89212c31d" +checksum = "0dd76fab81213d184cc28a7757791775bdcfd7f2a15e3558d7a4f7e4ee7de864" dependencies = [ "itertools 0.14.0", "raw-cpuid", @@ -7347,16 +7342,6 @@ dependencies = [ "font-types 0.10.1", ] -[[package]] -name = "read-fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" -dependencies = [ - "bytemuck", - "font-types 0.11.3", -] - [[package]] name = "read-fonts" version = "0.39.2" @@ -7429,14 +7414,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -7447,7 +7432,7 @@ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -7458,9 +7443,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -7497,6 +7482,14 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rimalloc" +version = "0.1.0" +dependencies = [ + "ctor 1.0.7", + "libc", +] + [[package]] name = "ring" version = "0.17.14" @@ -7710,9 +7703,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "num-traits", @@ -8300,16 +8293,6 @@ dependencies = [ "read-fonts 0.35.0", ] -[[package]] -name = "skrifa" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" -dependencies = [ - "bytemuck", - "read-fonts 0.37.0", -] - [[package]] name = "skrifa" version = "0.42.1" @@ -8337,9 +8320,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -8673,11 +8656,11 @@ dependencies = [ [[package]] name = "swash" -version = "0.2.7" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" dependencies = [ - "skrifa 0.40.0", + "skrifa 0.42.1", "yazi", "zeno", ] @@ -8751,7 +8734,7 @@ dependencies = [ "fnv", "once_cell", "plist", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "serde", "serde_derive", "serde_json", @@ -8920,7 +8903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "utf8-ranges", ] @@ -9109,12 +9092,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "js-sys", "num-conv", "powerfmt", @@ -9125,15 +9107,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -9473,7 +9455,7 @@ checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -9736,7 +9718,7 @@ dependencies = [ "qcms", "rayon", "regex", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", "roxmltree 0.20.0", "rust_decimal", "rustc-hash 2.1.2", @@ -10122,9 +10104,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -10251,9 +10233,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -10269,9 +10251,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -10282,9 +10264,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -10292,9 +10274,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10302,9 +10284,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -10315,9 +10297,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -10514,9 +10496,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -11670,18 +11652,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -11711,18 +11693,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7cdeca66..7e26a214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ hotpath = "0.16.1" ignore = "0.4.25" imagesize = "0.13" lopdf = "0.40.0" -mimalloc = "0.1.52" +rimalloc = { path = "vendor/rimalloc" } mitex = "0.2" notify = "8.2.0" quick-xml = "0.36.2" diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index f7bebbc6..2163bc49 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -22,7 +22,7 @@ gpui-component = { workspace = true } gpui-component-assets = { workspace = true } hotpath = { workspace = true } memchr = "2.8.1" -mimalloc = { workspace = true } +rimalloc = { workspace = true } palette = "0.7.6" rustc-hash = { workspace = true } serde = { workspace = true } diff --git a/crates/flowstate/src/main.rs b/crates/flowstate/src/main.rs index 2af3b150..a9bd1803 100644 --- a/crates/flowstate/src/main.rs +++ b/crates/flowstate/src/main.rs @@ -19,19 +19,19 @@ impl Default for FlowstateAllocator { } // SAFETY: This allocator delegates all allocation operations directly to -// mimalloc's `GlobalAlloc` implementation without changing the pointer, +// rimalloc's `GlobalAlloc` implementation without changing the pointer, // layout, or ownership contracts. unsafe impl GlobalAlloc for FlowstateAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { // SAFETY: The caller upholds `GlobalAlloc::alloc`'s layout contract, and - // the layout is forwarded unchanged to mimalloc. - unsafe { mimalloc::MiMalloc.alloc(layout) } + // the layout is forwarded unchanged to rimalloc. + unsafe { rimalloc::Rimalloc.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { // SAFETY: The caller guarantees `ptr` and `layout` came from this - // allocator; both are forwarded unchanged to mimalloc. - unsafe { mimalloc::MiMalloc.dealloc(ptr, layout) } + // allocator; both are forwarded unchanged to rimalloc. + unsafe { rimalloc::Rimalloc.dealloc(ptr, layout) } } } diff --git a/vendor/rimalloc/Cargo.toml b/vendor/rimalloc/Cargo.toml new file mode 100644 index 00000000..4ff9dbd4 --- /dev/null +++ b/vendor/rimalloc/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rimalloc" +description = "A pure-Rust port of mimalloc: a compact, fast general-purpose allocator" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/moggedhedien/rimalloc" + +[dependencies] +libc = { version = "0.2", default-features = false } +ctor = { version = "1.0.7", default-features = false, features = ["proc_macro", "std"] } + +[features] +default = [] +stats = [] +secure = [] +debug-fill = [] +nightly = [] + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" +missing_debug_implementations = "allow" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)', 'cfg(fuzzing)'] } diff --git a/vendor/rimalloc/examples/global.rs b/vendor/rimalloc/examples/global.rs new file mode 100644 index 00000000..08b02ec9 --- /dev/null +++ b/vendor/rimalloc/examples/global.rs @@ -0,0 +1,37 @@ +use rimalloc::Rimalloc; + +#[global_allocator] +static ALLOC: Rimalloc = Rimalloc; + +fn main() { + // exercise Vec/String/HashMap through the global allocator + let mut v: Vec = Vec::new(); + for i in 0..1_000_000 { + v.push(i); + } + assert_eq!(v.iter().sum::(), 999_999 * 1_000_000 / 2); + + let mut m = std::collections::HashMap::new(); + for i in 0..100_000u64 { + m.insert(i, i.to_string()); + } + assert_eq!(m[&99_999], "99999"); + drop(m); + + let handles: Vec<_> = (0..8) + .map(|t| { + std::thread::spawn(move || { + let mut buf = Vec::new(); + for i in 0..50_000usize { + buf.push(vec![t as u8; (i % 512) + 1]); + if i % 3 == 0 { + buf.swap_remove(buf.len() / 2); + } + } + buf.len() + }) + }) + .collect(); + let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum(); + println!("global allocator OK ({total} survivors)"); +} diff --git a/vendor/rimalloc/examples/smoke.rs b/vendor/rimalloc/examples/smoke.rs new file mode 100644 index 00000000..b953572f --- /dev/null +++ b/vendor/rimalloc/examples/smoke.rs @@ -0,0 +1,102 @@ +fn main() { + // basic alloc/free + let p = rimalloc::malloc(42); + assert!(!p.is_null()); + assert!(rimalloc::usable_size(p) >= 42); + unsafe { std::ptr::write_bytes(p, 0xab, 42) }; + rimalloc::free(p); + + // many sizes + let mut ptrs = Vec::new(); + for i in 0..10000usize { + let size = (i % 4000) + 1; + let p = rimalloc::malloc(size); + assert!(!p.is_null(), "alloc {size}"); + unsafe { std::ptr::write_bytes(p, (i & 0xff) as u8, size) }; + ptrs.push((p, size, (i & 0xff) as u8)); + } + for (p, size, v) in &ptrs { + unsafe { + for j in 0..*size { + assert_eq!(*p.add(j), *v, "corruption at {j} of {size}"); + } + } + rimalloc::free(*p); + } + + // zalloc + let p = rimalloc::zalloc(1024 * 1024); + unsafe { + for j in 0..1024 * 1024 { + assert_eq!(*p.add(j), 0); + } + } + rimalloc::free(p); + + // large + huge + for size in [100_000, 1_000_000, 20_000_000, 40_000_000] { + let p = rimalloc::malloc(size); + assert!(!p.is_null()); + unsafe { + *p = 1; + *p.add(size - 1) = 2; + } + rimalloc::free(p); + } + + // aligned + for align in [16usize, 64, 256, 4096, 65536, 1 << 20] { + let p = rimalloc::malloc_aligned(1000, align); + assert!(!p.is_null()); + assert_eq!(p.addr() % align, 0, "align {align}"); + unsafe { std::ptr::write_bytes(p, 0xcd, 1000) }; + assert!(rimalloc::usable_size(p) >= 1000); + rimalloc::free(p); + } + + // realloc + let mut p = rimalloc::malloc(10); + unsafe { std::ptr::write_bytes(p, 7, 10) }; + p = rimalloc::realloc(p, 100_000); + unsafe { + for j in 0..10 { + assert_eq!(*p.add(j), 7); + } + } + rimalloc::free(p); + + // cross-thread free + let mut handles = Vec::new(); + let (tx, rx) = std::sync::mpsc::channel::<(usize, usize)>(); + for _ in 0..4 { + let tx = tx.clone(); + handles.push(std::thread::spawn(move || { + for i in 0..1000usize { + let size = (i % 2048) + 8; + let p = rimalloc::malloc(size); + unsafe { std::ptr::write_bytes(p, 0x5a, size) }; + tx.send((p.addr(), size)).unwrap(); + } + })); + } + drop(tx); + let mut received = Vec::new(); + while let Ok((addr, size)) = rx.recv() { + received.push((addr, size)); + } + for h in handles { + h.join().unwrap(); + } + for (addr, size) in received { + let p = std::ptr::with_exposed_provenance_mut::(addr); + unsafe { + for j in 0..size { + assert_eq!(*p.add(j), 0x5a); + } + } + rimalloc::free(p); + } + rimalloc::collect(true); + rimalloc::stats_print(); + println!("rimalloc smoke: OK"); +} diff --git a/vendor/rimalloc/examples/stress.rs b/vendor/rimalloc/examples/stress.rs new file mode 100644 index 00000000..f08c8509 --- /dev/null +++ b/vendor/rimalloc/examples/stress.rs @@ -0,0 +1,173 @@ +//! Faithful port of mimalloc `test/test-stress.c`: multiple threads +//! allocating linearly-distributed power-of-two sizes, transferring blocks +//! between threads, with threads destroyed and recreated each iteration. +//! +//! Usage: stress [THREADS] [SCALE] [ITER] + +use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; + +const TRANSFERS: usize = 1000; +const COOKIE: usize = 0xbf58476d1ce4e5b9; + +static TRANSFER: [AtomicPtr; TRANSFERS] = + [const { AtomicPtr::new(std::ptr::null_mut()) }; TRANSFERS]; + +static ALLOW_LARGE_OBJECTS: bool = false; + +/// splitmix64 (matching the C test) +fn pick(r: &mut usize) -> usize { + let mut x = *r; + x ^= x >> 30; + x = x.wrapping_mul(0xbf58476d1ce4e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d049bb133111eb); + x ^= x >> 31; + *r = x; + x +} + +fn chance(perc: usize, r: &mut usize) -> bool { + pick(r) % 100 <= perc +} + +fn alloc_items(mut items: usize, r: &mut usize) -> *mut usize { + if chance(1, r) { + if chance(1, r) && ALLOW_LARGE_OBJECTS { + items *= 10000; // 0.01% giant + } else if chance(10, r) && ALLOW_LARGE_OBJECTS { + items *= 1000; // 0.1% huge + } else { + items *= 100; // 1% large objects + } + } + if (32..=40).contains(&items) { + items *= 2; // pthreads uses 320b allocations + } + if items == 0 { + items = 1; + } + let p = rimalloc::calloc(items, size_of::()).cast::(); + if !p.is_null() { + for i in 0..items { + unsafe { + // C builds the reference harness with NDEBUG, compiling out + // its `assert(p[i] == 0)`; keep instruction parity here. + debug_assert_eq!(*p.add(i), 0); + *p.add(i) = (items - i) ^ COOKIE; + } + } + } + p +} + +fn free_items(p: *mut usize) { + if !p.is_null() { + unsafe { + let items = *p ^ COOKIE; + for i in 0..items { + assert_eq!( + *p.add(i) ^ COOKIE, + items - i, + "memory corruption at block {p:p} at {i}" + ); + } + } + } + rimalloc::free(p.cast()); +} + +fn stress(tid: usize, scale: usize) { + let mut r: usize = (tid + 1) * 43; + const MAX_ITEM_SHIFT: usize = 5; // 128 + const MAX_ITEM_RETAINED_SHIFT: usize = MAX_ITEM_SHIFT + 2; + let mut allocs = 100 * scale * (tid % 8 + 1); // some threads do more + let mut retain = allocs / 2; + let mut data: Vec<*mut usize> = Vec::new(); + let mut retained: Vec<*mut usize> = Vec::with_capacity(retain); + + while allocs > 0 || retain > 0 { + if retain == 0 || (chance(50, &mut r) && allocs > 0) { + // 50%+ alloc + allocs -= 1; + data.push(alloc_items(1 << (pick(&mut r) % MAX_ITEM_SHIFT), &mut r)); + } else { + // 25% retain + retained.push(alloc_items( + 1 << (pick(&mut r) % MAX_ITEM_RETAINED_SHIFT), + &mut r, + )); + retain -= 1; + } + if chance(66, &mut r) && !data.is_empty() { + // 66% free previous alloc + let idx = pick(&mut r) % data.len(); + free_items(data[idx]); + data[idx] = std::ptr::null_mut(); + } + if chance(25, &mut r) && !data.is_empty() { + // 25% exchange a local pointer with the (shared) transfer buffer + let data_idx = pick(&mut r) % data.len(); + let transfer_idx = pick(&mut r) % TRANSFERS; + let p = data[data_idx]; + let q = TRANSFER[transfer_idx].swap(p, Ordering::AcqRel); + data[data_idx] = q; + } + } + // free everything that is left + for p in retained { + free_items(p); + } + for p in data { + free_items(p); + } +} + +static SCALE: AtomicUsize = AtomicUsize::new(0); + +fn run_os_threads(nthreads: usize, scale: usize) { + SCALE.store(scale, Ordering::Relaxed); + let scale = SCALE.load(Ordering::Relaxed); + std::thread::scope(|s| { + for tid in 0..nthreads { + s.spawn(move || { + stress(tid, scale); + }); + } + }); +} + +fn main() { + let args: Vec = std::env::args().collect(); + let threads: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(32); + let scale: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(50); + let iter: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(50); + println!("stress: {threads} threads, scale {scale}, {iter} iterations"); + + let start = std::time::Instant::now(); + let mut r: usize = 42; + for n in 0..iter { + run_os_threads(threads, scale); + for t in &TRANSFER { + if chance(50, &mut r) || n + 1 == iter { + // free all on last run, otherwise free half + let p = t.swap(std::ptr::null_mut(), Ordering::AcqRel); + free_items(p); + } + } + if (n + 1) % 10 == 0 { + println!("- iterations left: {:3}", iter - (n + 1)); + } + } + rimalloc::collect(true); + println!( + "stress test completed successfully in {:.2?}", + start.elapsed() + ); + let [mmap, munmap, madvise, mprotect] = rimalloc::os_syscall_counts(); + println!("syscalls: mmap={mmap} munmap={munmap} madvise={madvise} mprotect={mprotect}"); + let (abandoned, tries, hits) = rimalloc::abandoned_stats(); + println!("abandoned={abandoned} reclaim_tries={tries} reclaim_hits={hits}"); + if std::env::var_os("STRESS_STATS").is_some() { + rimalloc::stats_print(); + } +} diff --git a/vendor/rimalloc/src/alloc.rs b/vendor/rimalloc/src/alloc.rs new file mode 100644 index 00000000..c3d37f1c --- /dev/null +++ b/vendor/rimalloc/src/alloc.rs @@ -0,0 +1,61 @@ +//! Allocation fast paths (port of `alloc.c`). + +use core::ptr::{self, NonNull}; + +use crate::constants::*; +use crate::heap::{Heap, heap_get_free_small_page}; +use crate::page::Block; +use crate::page::Page; +use crate::page_ops::malloc_generic; + +/// `_mi_page_malloc_zero`: pop a block from the page free list, or fall +/// into the generic path. ~7 instructions in the fast case. +#[inline(always)] +pub fn page_malloc_zero(heap: &Heap, page: &Page, size: usize, zero: bool) -> Option> { + debug_assert!(page.block_size.get() == 0 || page.block_size() >= size); + let block = page.free.get(); + if block.is_null() { + return malloc_generic(heap, size, zero, 0); + } + // SAFETY: free-list nodes are live blocks of this page. + page.free.set(unsafe { page.block_next(block) }); + page.used.set(page.used.get() + 1); + debug_assert!( + page.block_size() < MAX_ALIGN_SIZE || block.addr().is_multiple_of(MAX_ALIGN_SIZE) + ); + + if zero { + debug_assert!(page.block_size.get() != 0); + if page.free_is_zero.get() { + // SAFETY: the block is exclusively ours; clear the list word. + unsafe { Block::write_next(block, ptr::null_mut()) }; + } else { + // SAFETY: the block is block_size bytes, exclusively ours now. + unsafe { ptr::write_bytes(block.cast::(), 0, page.block_size()) }; + } + } else if cfg!(feature = "debug-fill") && !page.is_huge.get() { + // MI_DEBUG_UNINIT: stamp fresh blocks to surface uninitialized reads. + // SAFETY: the block is exclusively ours. + unsafe { ptr::write_bytes(block.cast::(), 0xD0, page.usable_block_size()) }; + } + NonNull::new(block.cast()) +} + +/// `mi_heap_malloc_small_zero` +#[inline(always)] +pub fn heap_malloc_small_zero(heap: &Heap, size: usize, zero: bool) -> Option> { + debug_assert!(size <= SMALL_SIZE_MAX); + // SAFETY: pages_free_direct entries are live pages or the static empty page. + let page = unsafe { &*heap_get_free_small_page(heap, size + PADDING_SIZE) }; + page_malloc_zero(heap, page, size + PADDING_SIZE, zero) +} + +/// `_mi_heap_malloc_zero_ex`: the main entry point. +#[inline(always)] +pub fn heap_malloc_zero(heap: &Heap, size: usize, zero: bool) -> Option> { + if size <= SMALL_SIZE_MAX { + heap_malloc_small_zero(heap, size, zero) + } else { + malloc_generic(heap, size + PADDING_SIZE, zero, 0) + } +} diff --git a/vendor/rimalloc/src/api.rs b/vendor/rimalloc/src/api.rs new file mode 100644 index 00000000..ec889492 --- /dev/null +++ b/vendor/rimalloc/src/api.rs @@ -0,0 +1,737 @@ +//! Public allocation API, mirroring `mimalloc.h` (`mi_*` functions) plus a +//! `GlobalAlloc` implementation. + +use core::alloc::{GlobalAlloc, Layout}; +use core::ptr::{self, NonNull}; + +use crate::alloc::heap_malloc_zero; +use crate::constants::*; +use crate::heap::Heap; +use crate::init::heap_default_unchecked; +use crate::page_ops::malloc_generic; +use crate::segment::ptr_segment; + +#[inline(always)] +fn as_ptr(p: Option>) -> *mut u8 { + p.map_or(ptr::null_mut(), NonNull::as_ptr) +} + +// --------------------------------------------------------------------------- +// malloc / zalloc / calloc / free +// --------------------------------------------------------------------------- + +/// `mi_malloc` +#[inline] +pub fn malloc(size: usize) -> *mut u8 { + as_ptr(heap_malloc_zero(heap_default_unchecked(), size, false)) +} + +/// `mi_zalloc` +#[inline] +pub fn zalloc(size: usize) -> *mut u8 { + as_ptr(heap_malloc_zero(heap_default_unchecked(), size, true)) +} + +/// `mi_malloc_small` (requires `size <= SMALL_SIZE_MAX`) +#[inline] +pub fn malloc_small(size: usize) -> *mut u8 { + debug_assert!(size <= SMALL_SIZE_MAX); + as_ptr(crate::alloc::heap_malloc_small_zero( + heap_default_unchecked(), + size, + false, + )) +} + +/// `mi_count_size_overflow`-checked multiply. +#[inline] +fn count_size(count: usize, size: usize) -> Option { + count.checked_mul(size).filter(|&t| t <= MAX_ALLOC_SIZE) +} + +/// `mi_calloc` +#[inline] +pub fn calloc(count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => zalloc(total), + None => ptr::null_mut(), + } +} + +/// `mi_mallocn` +#[inline] +pub fn mallocn(count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => malloc(total), + None => ptr::null_mut(), + } +} + +/// `mi_free` +#[inline] +pub fn free(p: *mut u8) { + crate::free::free(p) +} + +/// `mi_usable_size` +#[inline] +pub fn usable_size(p: *const u8) -> usize { + crate::free::usable_size(p) +} + +/// `mi_good_size` +#[inline] +pub fn good_size(size: usize) -> usize { + crate::bins::good_size(size) +} + +// --------------------------------------------------------------------------- +// realloc family +// --------------------------------------------------------------------------- + +/// Common tail of the realloc paths: copy into the new block (zeroing the +/// growth tail first), free the old block. +fn realloc_move(p: *mut u8, newp: *mut u8, oldsize: usize, newsize: usize, zero: bool) { + if zero && newsize > oldsize { + let start = oldsize.saturating_sub(size_of::()); + // SAFETY: newp has at least newsize usable bytes. + unsafe { ptr::write_bytes(newp.add(start), 0, newsize - start) }; + } + if newsize > 0 { + // SAFETY: disjoint allocations, both at least min(old,new) long. + unsafe { ptr::copy_nonoverlapping(p, newp, oldsize.min(newsize)) }; + } + free(p); +} + +/// `_mi_heap_realloc_zero` +fn heap_realloc_zero(heap: &Heap, p: *mut u8, newsize: usize, zero: bool) -> *mut u8 { + if p.is_null() { + return as_ptr(heap_malloc_zero(heap, newsize, zero)); + } + let size = usable_size(p); + // reallocation still fits and not more than 50% waste? + if newsize <= size && newsize >= size / 2 && newsize > 0 { + return p; + } + let newp = as_ptr(heap_malloc_zero(heap, newsize, false)); + if !newp.is_null() { + if newsize == 0 { + // applications expect zero-reallocation to be zeroed (C issue #725). + // SAFETY: a zero-size block still has a usable first byte. + unsafe { newp.write(0) }; + } + realloc_move(p, newp, size, newsize, zero); + } + newp +} + +/// `mi_realloc` +#[inline] +pub fn realloc(p: *mut u8, newsize: usize) -> *mut u8 { + heap_realloc_zero(heap_default_unchecked(), p, newsize, false) +} + +/// `mi_reallocn` +#[inline] +pub fn reallocn(p: *mut u8, count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => realloc(p, total), + None => ptr::null_mut(), + } +} + +/// `mi_rezalloc` +#[inline] +pub fn rezalloc(p: *mut u8, newsize: usize) -> *mut u8 { + heap_realloc_zero(heap_default_unchecked(), p, newsize, true) +} + +/// `mi_recalloc` +#[inline] +pub fn recalloc(p: *mut u8, count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => rezalloc(p, total), + None => ptr::null_mut(), + } +} + +/// `mi_reallocf`: free `p` on failure. +#[inline] +pub fn reallocf(p: *mut u8, newsize: usize) -> *mut u8 { + let newp = realloc(p, newsize); + if newp.is_null() && !p.is_null() { + free(p); + } + newp +} + +/// `mi_expand`: in-place only. +#[inline] +pub fn expand(p: *mut u8, newsize: usize) -> *mut u8 { + if p.is_null() { + return ptr::null_mut(); + } + if newsize > usable_size(p) { + ptr::null_mut() + } else { + p + } +} + +// --------------------------------------------------------------------------- +// Aligned allocation +// --------------------------------------------------------------------------- + +/// `mi_malloc_is_naturally_aligned` +fn is_naturally_aligned(size: usize, alignment: usize) -> bool { + debug_assert!(is_power_of_two(alignment)); + if alignment > size { + return false; + } + if alignment <= MAX_ALIGN_SIZE { + return true; + } + let bsize = good_size(size); + bsize <= MAX_ALIGN_GUARANTEE && bsize & (alignment - 1) == 0 +} + +/// `mi_heap_malloc_zero_aligned_at_overalloc` +fn malloc_aligned_overalloc( + heap: &Heap, + size: usize, + alignment: usize, + offset: usize, + zero: bool, +) -> *mut u8 { + let p = if alignment > BLOCK_ALIGNMENT_MAX { + // dedicated huge segment, aligned within the single page + if offset != 0 { + crate::error("aligned allocation with very large alignment cannot use an offset"); + return ptr::null_mut(); + } + let oversize = if size <= SMALL_SIZE_MAX { + SMALL_SIZE_MAX + 1 + } else { + size + }; + as_ptr(malloc_generic(heap, oversize, false, alignment)) + } else { + let oversize = size.max(MAX_ALIGN_SIZE) + alignment - 1; + as_ptr(heap_malloc_zero(heap, oversize, zero)) + }; + if p.is_null() { + return ptr::null_mut(); + } + // SAFETY: p was just allocated from a live page. + let page = unsafe { crate::segment::ptr_page(p) }; + let align_mask = alignment - 1; + let poffset = (p.addr() + offset) & align_mask; + let adjust = if poffset == 0 { 0 } else { alignment - poffset }; + // SAFETY: adjust < alignment <= usable slack of the oversized block. + let aligned_p = unsafe { p.add(adjust) }; + if aligned_p != p { + page.set_has_aligned(true); + } + debug_assert!((aligned_p.addr() + offset).is_multiple_of(alignment)); + if alignment > BLOCK_ALIGNMENT_MAX && zero { + let usable = crate::free::usable_size(aligned_p); + // SAFETY: aligned_p..+usable is within the block. + unsafe { ptr::write_bytes(aligned_p, 0, usable) }; + } + aligned_p +} + +/// `mi_heap_malloc_zero_aligned_at` +fn malloc_zero_aligned_at( + heap: &Heap, + size: usize, + alignment: usize, + offset: usize, + zero: bool, +) -> *mut u8 { + if alignment == 0 || !is_power_of_two(alignment) { + return ptr::null_mut(); + } + if size > MAX_ALLOC_SIZE { + return ptr::null_mut(); + } + // Fast path: a small block with the right alignment. + if offset == 0 && alignment <= size && size <= SMALL_SIZE_MAX { + // SAFETY: pages_free_direct entries are live or the static empty page. + let page = unsafe { &*crate::heap::heap_get_free_small_page(heap, size + PADDING_SIZE) }; + let head = page.free.get(); + if !head.is_null() && head.addr() & (alignment - 1) == 0 { + return as_ptr(crate::alloc::page_malloc_zero( + heap, + page, + size + PADDING_SIZE, + zero, + )); + } + } + if offset == 0 && is_naturally_aligned(size, alignment) { + let p = as_ptr(heap_malloc_zero(heap, size, zero)); + debug_assert!(p.is_null() || p.addr().is_multiple_of(alignment)); + if p.addr() & (alignment - 1) == 0 { + return p; + } + free(p); + } + malloc_aligned_overalloc(heap, size, alignment, offset, zero) +} + +/// `mi_malloc_aligned` +#[inline] +pub fn malloc_aligned(size: usize, alignment: usize) -> *mut u8 { + malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, false) +} + +/// `mi_malloc_aligned_at` +#[inline] +pub fn malloc_aligned_at(size: usize, alignment: usize, offset: usize) -> *mut u8 { + malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, offset, false) +} + +/// `mi_zalloc_aligned` +#[inline] +pub fn zalloc_aligned(size: usize, alignment: usize) -> *mut u8 { + malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, true) +} + +/// `mi_zalloc_aligned_at` +#[inline] +pub fn zalloc_aligned_at(size: usize, alignment: usize, offset: usize) -> *mut u8 { + malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, offset, true) +} + +/// `mi_calloc_aligned` +#[inline] +pub fn calloc_aligned(count: usize, size: usize, alignment: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => zalloc_aligned(total, alignment), + None => ptr::null_mut(), + } +} + +/// `mi_calloc_aligned_at` +#[inline] +pub fn calloc_aligned_at(count: usize, size: usize, alignment: usize, offset: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => zalloc_aligned_at(total, alignment, offset), + None => ptr::null_mut(), + } +} + +fn realloc_zero_aligned_at( + p: *mut u8, + newsize: usize, + alignment: usize, + offset: usize, + zero: bool, +) -> *mut u8 { + if !is_power_of_two(alignment) { + return ptr::null_mut(); + } + if p.is_null() { + return malloc_zero_aligned_at(heap_default_unchecked(), newsize, alignment, offset, zero); + } + let size = usable_size(p); + // still fits, aligned, and not more than ~25% waste (matches C)? + if newsize <= size + && newsize >= size - size / 2 + && (p.addr() + offset).is_multiple_of(alignment) + { + return p; + } + let newp = malloc_zero_aligned_at(heap_default_unchecked(), newsize, alignment, offset, zero); + if !newp.is_null() { + if newsize == 0 { + // SAFETY: a zero-size block still has a usable first byte. + unsafe { newp.write(0) }; + } + realloc_move(p, newp, size, newsize, zero); + } + newp +} + +/// `mi_realloc_aligned` +#[inline] +pub fn realloc_aligned(p: *mut u8, newsize: usize, alignment: usize) -> *mut u8 { + realloc_zero_aligned_at(p, newsize, alignment, 0, false) +} + +/// `mi_realloc_aligned_at` +#[inline] +pub fn realloc_aligned_at(p: *mut u8, newsize: usize, alignment: usize, offset: usize) -> *mut u8 { + realloc_zero_aligned_at(p, newsize, alignment, offset, false) +} + +/// `mi_rezalloc_aligned` +#[inline] +pub fn rezalloc_aligned(p: *mut u8, newsize: usize, alignment: usize) -> *mut u8 { + realloc_zero_aligned_at(p, newsize, alignment, 0, true) +} + +/// `mi_recalloc_aligned` +#[inline] +pub fn recalloc_aligned(p: *mut u8, count: usize, size: usize, alignment: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => rezalloc_aligned(p, total, alignment), + None => ptr::null_mut(), + } +} + +// --------------------------------------------------------------------------- +// Heap API +// --------------------------------------------------------------------------- + +/// `mi_heap_new` +pub fn heap_new() -> Option<&'static Heap> { + crate::heap_ops::heap_new(true, 0, crate::arena::ARENA_ID_NONE) +} + +/// `mi_heap_new_ex`: custom tag, destroy permission, and arena binding. +pub fn heap_new_ex(tag: u8, allow_destroy: bool, arena_id: usize) -> Option<&'static Heap> { + crate::heap_ops::heap_new(allow_destroy, tag, arena_id) +} + +/// `mi_heap_new_in_arena`: a heap allocating exclusively from `arena_id`. +pub fn heap_new_in_arena(arena_id: usize) -> Option<&'static Heap> { + crate::heap_ops::heap_new(false, 0, arena_id) +} + +/// `mi_reserve_os_memory_ex`: pre-reserve OS memory as an arena. Returns +/// the arena id (use with [`heap_new_in_arena`] when `exclusive`). +pub fn reserve_os_memory(size: usize, commit: bool, exclusive: bool) -> Option { + crate::arena::reserve_os_memory(size, commit, exclusive) +} + +/// `mi_manage_os_memory_ex`: adopt caller-provided memory as an arena +/// (never freed by rimalloc). +/// +/// # Safety +/// See [`crate::arena::manage_os_memory`]. +pub unsafe fn manage_os_memory( + start: *mut u8, + size: usize, + is_committed: bool, + is_zero: bool, + numa_node: i32, + exclusive: bool, +) -> Option { + // SAFETY: per contract. + unsafe { + crate::arena::manage_os_memory(start, size, is_committed, is_zero, numa_node, exclusive) + } +} + +/// `mi_reserve_huge_os_pages_at`: reserve `pages` 1GiB huge OS pages as an +/// arena on `numa_node`. Returns the arena id, or `None` where huge OS +/// pages are unsupported (e.g. macOS arm64). +pub fn reserve_huge_os_pages_at(pages: usize, numa_node: i32) -> Option { + crate::os::alloc_huge_os_pages(pages).and_then(|(start, mem)| { + // SAFETY: fresh exclusive mapping from the OS. + unsafe { crate::arena::manage_os_memory(start, mem.size, true, true, numa_node, false) } + }) +} + +/// `mi_heap_get_default` +pub fn heap_get_default() -> &'static Heap { + crate::init::heap_default() +} + +/// `mi_heap_get_backing` +pub fn heap_get_backing() -> &'static Heap { + let heap = heap_get_default(); + // SAFETY: tld and backing heap live for the thread lifetime. + unsafe { &*(*heap.tld.get()).heap_backing.get() } +} + +/// `mi_heap_set_default` +pub fn heap_set_default(heap: &Heap) -> &'static Heap { + crate::init::heap_set_default(heap) +} + +/// `mi_heap_delete` +pub fn heap_delete(heap: &Heap) { + crate::heap_ops::heap_delete(heap) +} + +/// `mi_heap_destroy` +pub fn heap_destroy(heap: &Heap) { + crate::heap_ops::heap_destroy(heap) +} + +/// `mi_heap_malloc` +#[inline] +pub fn heap_malloc(heap: &Heap, size: usize) -> *mut u8 { + as_ptr(heap_malloc_zero(heap, size, false)) +} + +/// `mi_heap_zalloc` +#[inline] +pub fn heap_zalloc(heap: &Heap, size: usize) -> *mut u8 { + as_ptr(heap_malloc_zero(heap, size, true)) +} + +/// `mi_heap_calloc` +#[inline] +pub fn heap_calloc(heap: &Heap, count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => heap_zalloc(heap, total), + None => ptr::null_mut(), + } +} + +/// `mi_heap_mallocn` +#[inline] +pub fn heap_mallocn(heap: &Heap, count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => heap_malloc(heap, total), + None => ptr::null_mut(), + } +} + +/// `mi_heap_realloc` +#[inline] +pub fn heap_realloc(heap: &Heap, p: *mut u8, newsize: usize) -> *mut u8 { + heap_realloc_zero(heap, p, newsize, false) +} + +/// `mi_heap_malloc_aligned` +#[inline] +pub fn heap_malloc_aligned(heap: &Heap, size: usize, alignment: usize) -> *mut u8 { + malloc_zero_aligned_at(heap, size, alignment, 0, false) +} + +/// `mi_collect` +pub fn collect(force: bool) { + crate::heap_ops::heap_collect(heap_get_default(), force); + crate::arena::try_purge(force); + if force { + crate::os::cache_collect(); + } + crate::os::drain_deferred(); +} + +/// `mi_thread_init` +pub fn thread_init() { + crate::init::thread_init(); +} + +/// `mi_thread_done` +pub fn thread_done() { + crate::init::thread_done(); +} + +// --------------------------------------------------------------------------- +// Introspection +// --------------------------------------------------------------------------- + +/// `mi_is_in_heap_region` (approximation: do we own the containing segment). +/// +/// # Safety +/// `p` must be null or a pointer into memory obtained from this allocator. +pub unsafe fn check_owned(p: *const u8) -> bool { + if p.is_null() || p.addr() & (PTR_SIZE - 1) != 0 { + return false; + } + if crate::arena::contains(p) { + return true; + } + // SAFETY: only reads the segment header derived from the address; a + // wrong address may fault (debug-only helper). + let segment = unsafe { ptr_segment(p) }; + segment.cookie.get() == segment.as_ptr().addr() ^ 0xa5a5_5a5a_1234_5678 +} + +/// `mi_is_in_heap_region`: true if `p` lies inside rimalloc-managed memory +/// (arena reservations); always safe to call. +pub fn is_in_heap_region(p: *const u8) -> bool { + !p.is_null() && crate::arena::contains(p) +} + +// --------------------------------------------------------------------------- +// GlobalAlloc +// --------------------------------------------------------------------------- + +/// A `#[global_allocator]`-compatible handle to rimalloc. +#[derive(Clone, Copy, Default, Debug)] +pub struct Rimalloc; + +// SAFETY: rimalloc is a conforming allocator: blocks stay valid until freed, +// Layout size/align contracts are respected via the aligned paths. +unsafe impl GlobalAlloc for Rimalloc { + #[inline] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // `align <= MAX_ALIGN_SIZE` is NOT sufficient: the 8-byte size class + // is only 8-aligned, so a `{size: 1..=8, align: 16}` layout needs the + // aligned path. `is_naturally_aligned` is C's exact predicate. + if is_naturally_aligned(layout.size(), layout.align()) { + malloc(layout.size()) + } else { + malloc_aligned(layout.size(), layout.align()) + } + } + + #[inline] + unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { + free(ptr) + } + + #[inline] + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + if is_naturally_aligned(layout.size(), layout.align()) { + zalloc(layout.size()) + } else { + zalloc_aligned(layout.size(), layout.align()) + } + } + + #[inline] + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if is_naturally_aligned(new_size, layout.align()) { + realloc(ptr, new_size) + } else { + realloc_aligned(ptr, new_size, layout.align()) + } + } +} + +// --------------------------------------------------------------------------- +// POSIX-style API (port of `alloc-posix.c`) +// --------------------------------------------------------------------------- + +/// `mi_posix_memalign`: returns 0, or EINVAL/ENOMEM like posix_memalign. +pub fn posix_memalign(out: &mut *mut u8, alignment: usize, size: usize) -> i32 { + if !alignment.is_multiple_of(size_of::()) || !is_power_of_two(alignment) { + return crate::EINVAL; + } + let p = malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, false); + if p.is_null() { + return crate::ENOMEM; + } + *out = p; + 0 +} + +/// `mi_memalign` +pub fn memalign(alignment: usize, size: usize) -> *mut u8 { + malloc_aligned(size, alignment) +} + +/// `mi_valloc` +pub fn valloc(size: usize) -> *mut u8 { + malloc_aligned(size, crate::os::page_size()) +} + +/// `mi_reallocarray` +pub fn reallocarray(p: *mut u8, count: usize, size: usize) -> *mut u8 { + match count_size(count, size) { + Some(total) => realloc(p, total), + None => ptr::null_mut(), + } +} + +// --------------------------------------------------------------------------- +// Usable-size returning variants (`mi_umalloc` family) +// --------------------------------------------------------------------------- + +/// `mi_umalloc` +pub fn umalloc(size: usize, usable: &mut usize) -> *mut u8 { + let p = malloc(size); + *usable = usable_size(p); + p +} + +/// `mi_urealloc` +pub fn urealloc(p: *mut u8, newsize: usize, pre: &mut usize, post: &mut usize) -> *mut u8 { + *pre = usable_size(p); + let q = realloc(p, newsize); + *post = usable_size(q); + q +} + +/// `mi_ufree` +pub fn ufree(p: *mut u8, usable: &mut usize) { + *usable = usable_size(p); + free(p); +} + +// --------------------------------------------------------------------------- +// Heap introspection +// --------------------------------------------------------------------------- + +/// `mi_heap_contains_block` +/// +/// # Safety +/// `p` must be null or a block allocated by this allocator. +pub unsafe fn heap_contains_block(heap: &Heap, p: *const u8) -> bool { + if p.is_null() { + return false; + } + // SAFETY: p must be a block allocated by us (API contract). + let page = unsafe { crate::segment::ptr_page(p) }; + page.heap() == ptr::from_ref(heap).cast_mut() +} + +/// `mi_heap_check_owned` +/// +/// # Safety +/// `p` must be null or a pointer into memory obtained from this allocator. +pub unsafe fn heap_check_owned(_heap: &Heap, p: *const u8) -> bool { + // SAFETY: per contract. + unsafe { check_owned(p) } +} + +/// `mi_zalloc_small` +#[inline] +pub fn zalloc_small(size: usize) -> *mut u8 { + debug_assert!(size <= SMALL_SIZE_MAX); + as_ptr(crate::alloc::heap_malloc_small_zero( + heap_default_unchecked(), + size, + true, + )) +} + +/// Current committed-byte count from the stats (diagnostic). +pub fn stats_committed_current() -> i64 { + crate::stats::STATS + .committed + .current + .load(core::sync::atomic::Ordering::Relaxed) +} + +/// Syscall counters `[mmap, munmap, madvise, mprotect]` (diagnostics). +pub fn os_syscall_counts() -> [usize; 4] { + crate::os::syscall_counts() +} + +/// Diagnostics: (abandoned segments, reclaim tries, reclaim hits). +pub fn abandoned_stats() -> (usize, usize, usize) { + use core::sync::atomic::Ordering; + ( + crate::segment::abandoned::count(), + crate::segment::abandoned::RECLAIM_TRIES.load(Ordering::Relaxed), + crate::segment::abandoned::RECLAIM_HITS.load(Ordering::Relaxed), + ) +} + +/// Diagnostics: number of initialized live threads. +pub fn current_thread_count_dbg() -> usize { + crate::init::current_thread_count() +} + +/// `mi_stats_print`: write allocator statistics to stderr. +pub fn stats_print() { + let _ = crate::stats::print(&mut std::io::stderr()); +} + +/// Write allocator statistics to an arbitrary writer. +pub fn stats_write(out: &mut dyn std::io::Write) -> std::io::Result<()> { + crate::stats::print(out) +} + +pub use crate::hooks::{register_deferred_free, register_error, register_output}; diff --git a/vendor/rimalloc/src/arena.rs b/vendor/rimalloc/src/arena.rs new file mode 100644 index 00000000..574d0d59 --- /dev/null +++ b/vendor/rimalloc/src/arena.rs @@ -0,0 +1,426 @@ +//! Arenas (port of `arena.c`): large OS reservations (1GiB+, growing +//! geometrically) divided into segment-sized blocks tracked by atomic +//! bitmaps. Segments are claimed/released with no syscalls; freed blocks +//! are purged (`MADV_FREE`) lazily after a delay, keeping RSS bounded. + +use core::ptr; +use core::sync::atomic::{AtomicBool, AtomicI64, AtomicPtr, AtomicUsize, Ordering}; + +use crate::bitmap::{ + FIELD_BITS, claim_run_field, claim_single_field, clear_committed_run, decommit_run, field_mask, + prepare_run, purge_field, release_run, +}; +use crate::constants::*; +use crate::options; +use crate::os::{self, OsMem}; + +/// One arena block backs exactly one normal segment. +pub const ARENA_BLOCK_SIZE: usize = SEGMENT_SIZE; +const MAX_ARENAS: usize = 64; +/// Up to 16 GiB per arena (512 blocks, 8 bitmap fields). +const MAX_BLOCK_FIELDS: usize = 8; +const MAX_ARENA_BLOCKS: usize = MAX_BLOCK_FIELDS * FIELD_BITS; + +/// Arena identifier: 0 is "none" (any arena); real ids are `index + 1`. +pub type ArenaId = usize; +pub const ARENA_ID_NONE: ArenaId = 0; + +pub struct Arena { + id: ArenaId, + exclusive: bool, + numa_node: i32, + start: *mut u8, + block_count: usize, + #[allow(dead_code)] // owns the reservation for the process lifetime + mem: OsMem, + purge_expire: AtomicI64, + claim_lock: AtomicBool, // serializes multi-block claims only + blocks_inuse: [AtomicUsize; MAX_BLOCK_FIELDS], + blocks_dirty: [AtomicUsize; MAX_BLOCK_FIELDS], // ever used (not zero) + blocks_committed: [AtomicUsize; MAX_BLOCK_FIELDS], + blocks_purge: [AtomicUsize; MAX_BLOCK_FIELDS], // scheduled for purge +} + +// SAFETY: all mutable state is atomic. +unsafe impl Sync for Arena {} +// SAFETY: Arena owns its reservation; the raw pointers are plain data. +unsafe impl Send for Arena {} + +static ARENAS: [AtomicPtr; MAX_ARENAS] = + [const { AtomicPtr::new(ptr::null_mut()) }; MAX_ARENAS]; +static ARENA_COUNT: AtomicUsize = AtomicUsize::new(0); +/// Earliest pending purge across all arenas (0 = none): lock-free gate so +/// the allocation path never scans bitmaps needlessly. +static PURGE_EXPIRE: AtomicI64 = AtomicI64::new(0); +static RESERVE_LOCK: AtomicBool = AtomicBool::new(false); + +impl Arena { + #[inline] + fn block_ptr(&self, index: usize) -> *mut u8 { + // SAFETY: index < block_count, within the reservation. + unsafe { self.start.add(index * ARENA_BLOCK_SIZE) } + } + + /// Try to claim `count` contiguous blocks. Single blocks are lock-free; + /// multi-block claims serialize on a per-arena spinlock (rare: only + /// huge segments). + fn try_claim(&self, count: usize) -> Option { + let fields = self.block_count.div_ceil(FIELD_BITS); + if count == 1 { + for (i, field) in self.blocks_inuse[..fields].iter().enumerate() { + let limit = (self.block_count - i * FIELD_BITS).min(FIELD_BITS); + if let Some(bit) = claim_single_field(field, limit) { + return Some(i * FIELD_BITS + bit); + } + } + return None; + } + if count > FIELD_BITS { + return None; // >2GiB objects fall back to the OS + } + // multi-block: spinlock + scan within fields + crate::sync::spin_acquire(&self.claim_lock); + let mut found = None; + for (i, field) in self.blocks_inuse[..fields].iter().enumerate() { + let limit = (self.block_count - i * FIELD_BITS).min(FIELD_BITS); + if let Some(bit) = claim_run_field(field, limit, count) { + found = Some(i * FIELD_BITS + bit); + break; + } + } + crate::sync::spin_release(&self.claim_lock); + found + } + + /// Post-claim bookkeeping: ensure committed, compute zero/commit state. + fn prepare(&self, index: usize, count: usize) -> (bool, bool) { + let (fi, bit) = (index / FIELD_BITS, index % FIELD_BITS); + let is_zero = prepare_run( + &self.blocks_dirty[fi], + &self.blocks_committed[fi], + &self.blocks_purge[fi], + bit, + count, + || { + // SAFETY: the claimed range is exclusively ours. + unsafe { os::commit(self.block_ptr(index), count * ARENA_BLOCK_SIZE) }; + }, + ); + (is_zero, true) + } + + fn release(&self, index: usize, count: usize) { + let (fi, bit) = (index / FIELD_BITS, index % FIELD_BITS); + // schedule the physical pages for release before making the blocks + // claimable again + let expire = options::clock_now() + options::purge_delay().max(1); + self.purge_expire.store(expire, Ordering::Relaxed); + let cur = PURGE_EXPIRE.load(Ordering::Relaxed); + if cur == 0 || expire < cur { + PURGE_EXPIRE.store(expire, Ordering::Relaxed); + } + let prev = release_run(&self.blocks_inuse[fi], &self.blocks_purge[fi], bit, count); + debug_assert!(prev & field_mask(bit, count) == field_mask(bit, count)); + } + + /// Purge scheduled blocks that are still free (best effort; a racing + /// claim simply refaults). + fn purge_now(&self) { + let fields = self.block_count.div_ceil(FIELD_BITS); + let decommits = options::purge_decommits() != 0; + for fi in 0..fields { + purge_field( + &self.blocks_purge[fi], + &self.blocks_inuse[fi], + |bit, run_len| { + let index = fi * FIELD_BITS + bit; + if decommits { + // Take the run out of circulation while decommitting: + // a concurrent claim that saw the committed bits set + // must not touch memory mid-decommit. Skip runs that + // were (re)claimed since the purgeable snapshot. + decommit_run( + &self.blocks_inuse[fi], + &self.blocks_committed[fi], + bit, + run_len, + || { + // SAFETY: free blocks, exclusively held by us. + unsafe { + os::decommit(self.block_ptr(index), run_len * ARENA_BLOCK_SIZE); + } + }, + ); + } else { + // SAFETY: free blocks; contents may be discarded. + unsafe { + os::reset(self.block_ptr(index), run_len * ARENA_BLOCK_SIZE); + } + } + }, + ); + } + self.purge_expire.store(0, Ordering::Relaxed); + } +} + +/// Reserve a fresh arena. Size grows geometrically with the arena count. +fn arena_reserve() -> Option<&'static Arena> { + crate::sync::spin_locked(&RESERVE_LOCK, arena_reserve_locked) +} + +fn arena_reserve_locked() -> Option<&'static Arena> { + // MIMALLOC_ARENA_RESERVE (KiB) sets the base reservation; 0 disables + // arena reservation entirely (allocation falls back to the OS and the + // segment cache). + let reserve = crate::options::arena_reserve().max(0) as usize * 1024; + if reserve == 0 { + return None; + } + let n = ARENA_COUNT.load(Ordering::Acquire); + // base reservation, doubling every 2 arenas, capped at MAX_ARENA_BLOCKS. + let base_blocks = if cfg!(miri) { + 2 // miri: small host allocs + } else { + reserve.div_ceil(ARENA_BLOCK_SIZE).max(1) + }; + let blocks = (base_blocks << (n / 2)).min(MAX_ARENA_BLOCKS); + let size = blocks * ARENA_BLOCK_SIZE; + // Reserve uncommitted; blocks are committed on first claim. + let commit = cfg!(miri); + let (start, mem) = OsMem::alloc_aligned(size, SEGMENT_ALIGN, commit)?; + add_arena_locked(start, blocks, mem, false, -1, mem.is_zero) +} + +/// `mi_arena_add`: publish a new arena (assumes RESERVE_LOCK is held). +fn add_arena_locked( + start: *mut u8, + blocks: usize, + mem: OsMem, + exclusive: bool, + numa_node: i32, + is_zero: bool, +) -> Option<&'static Arena> { + let n = ARENA_COUNT.load(Ordering::Acquire); + if n >= MAX_ARENAS || blocks == 0 { + return None; + } + let header_size = align_up(size_of::(), os::page_size()); + let (hp, hmem) = OsMem::alloc_aligned(header_size, os::page_size(), true)?; + let arena_ptr = hp.cast::(); + // SAFETY: fresh committed memory sized for an Arena. + unsafe { + arena_ptr.write(Arena { + id: n + 1, + exclusive, + numa_node, + start, + block_count: blocks.min(MAX_ARENA_BLOCKS), + mem, + purge_expire: AtomicI64::new(0), + claim_lock: AtomicBool::new(false), + blocks_inuse: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], + blocks_dirty: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], + blocks_committed: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], + blocks_purge: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], + }); + if !is_zero { + (*arena_ptr).blocks_dirty.iter().for_each(|f| { + f.store(!0, Ordering::Relaxed); + }); + } + if mem.is_committed { + (*arena_ptr).blocks_committed.iter().for_each(|f| { + f.store(!0, Ordering::Relaxed); + }); + } + } + let _ = hmem; // header lives for the process lifetime + ARENAS[n].store(arena_ptr, Ordering::Release); + ARENA_COUNT.store(n + 1, Ordering::Release); + // SAFETY: just initialized; arenas are never freed. + Some(unsafe { &*arena_ptr }) +} + +/// `mi_arena_id_is_suitable` +#[inline] +fn id_is_suitable(id: ArenaId, exclusive: bool, req: ArenaId) -> bool { + (!exclusive && req == ARENA_ID_NONE) || id == req +} + +/// `_mi_arena_memid_is_suitable` for an [`ArenaRef`]. +pub fn ref_is_suitable(aref: ArenaRef, req: ArenaId) -> bool { + // SAFETY: arenas are immortal. + let arena = unsafe { &*aref.arena }; + id_is_suitable(arena.id, arena.exclusive, req) +} + +/// The current NUMA node (`_mi_os_numa_node`); single-node on macOS. +pub fn numa_node() -> i32 { + 0 +} + +fn arenas() -> impl Iterator { + let n = ARENA_COUNT.load(Ordering::Acquire); + ARENAS[..n].iter().filter_map(|slot| { + // SAFETY: published arenas are initialized and immortal. + unsafe { slot.load(Ordering::Acquire).as_ref() } + }) +} + +/// Provenance of an arena allocation (stored in the segment's `MemId`). +#[derive(Clone, Copy, Debug)] +pub struct ArenaRef { + arena: *const Arena, + index: u32, + count: u32, +} + +/// Allocate `count` contiguous arena blocks (SEGMENT_ALIGN aligned) from an +/// arena suitable for `req` (preferring the local NUMA node). +/// Returns `(ptr, is_zero, ArenaRef)`. +pub fn alloc(count: usize, req: ArenaId) -> Option<(*mut u8, bool, ArenaRef)> { + try_purge(false); + let node = numa_node(); + loop { + // pass 1: NUMA-local (or unbound) suitable arenas; pass 2: any suitable + for numa_strict in [true, false] { + for arena in arenas() { + if !id_is_suitable(arena.id, arena.exclusive, req) + || (numa_strict && arena.numa_node >= 0 && arena.numa_node != node) + { + continue; + } + if let Some(index) = arena.try_claim(count) { + let (is_zero, _committed) = arena.prepare(index, count); + let aref = ArenaRef { + arena: ptr::from_ref(arena), + index: index as u32, + count: count as u32, + }; + return Some((arena.block_ptr(index), is_zero, aref)); + } + } + } + if req != ARENA_ID_NONE { + return None; // a specific arena was requested and it is full + } + arena_reserve()?; + } +} + +/// `mi_reserve_os_memory_ex`: reserve fresh OS memory as an arena. +pub fn reserve_os_memory(size: usize, commit: bool, exclusive: bool) -> Option { + let blocks = size.div_ceil(ARENA_BLOCK_SIZE); + let (start, mem) = OsMem::alloc_aligned(blocks * ARENA_BLOCK_SIZE, SEGMENT_ALIGN, commit)?; + locked_add(start, blocks, mem, exclusive, -1, mem.is_zero) +} + +/// `mi_manage_os_memory_ex`: adopt externally-provided memory as an arena. +/// The memory is never freed by rimalloc. +/// +/// # Safety +/// `start..start+size` must be valid, otherwise-unused memory that outlives +/// the process's use of this allocator; `is_committed`/`is_zero` must be +/// accurate. +pub unsafe fn manage_os_memory( + start: *mut u8, + size: usize, + is_committed: bool, + is_zero: bool, + numa_node: i32, + exclusive: bool, +) -> Option { + // align inward to whole blocks + let astart = align_up(start.addr(), SEGMENT_ALIGN); + let aend = align_down(start.addr() + size, ARENA_BLOCK_SIZE.max(SEGMENT_ALIGN)); + if aend <= astart { + return None; + } + let blocks = (aend - astart) / ARENA_BLOCK_SIZE; + let astart_ptr = start.with_addr(astart); + let mem = OsMem { + base: astart_ptr, + size: blocks * ARENA_BLOCK_SIZE, + align: SEGMENT_ALIGN, + is_committed, + is_zero, + }; + astart_ptr.expose_provenance(); // segment lookups reconstruct by address + locked_add(astart_ptr, blocks, mem, exclusive, numa_node, is_zero) +} + +fn locked_add( + start: *mut u8, + blocks: usize, + mem: OsMem, + exclusive: bool, + numa_node: i32, + is_zero: bool, +) -> Option { + crate::sync::spin_locked(&RESERVE_LOCK, || { + add_arena_locked(start, blocks, mem, exclusive, numa_node, is_zero).map(|a| a.id) + }) +} + +/// Address range check (`mi_is_in_heap_region` support). +pub fn contains(p: *const u8) -> bool { + arenas().any(|a| { + p.addr() >= a.start.addr() && p.addr() < a.start.addr() + a.block_count * ARENA_BLOCK_SIZE + }) +} + +/// Return blocks to their arena. +pub fn free(aref: ArenaRef, all_committed: bool) { + // SAFETY: arenas are immortal. + let arena = unsafe { &*aref.arena }; + if !all_committed { + // The dying segment decommitted parts of this range (the arena + // tracks commitment at block granularity): mark the whole range + // uncommitted so the next claim recommits it (C `_mi_arena_free`), + // preventing a re-claim from handing out PROT_NONE memory. + let (fi, bit) = ( + aref.index as usize / FIELD_BITS, + aref.index as usize % FIELD_BITS, + ); + clear_committed_run(&arena.blocks_committed[fi], bit, aref.count as usize); + } + arena.release(aref.index as usize, aref.count as usize); +} + +/// Execute pending purges if due (cheap lock-free check on the fast path). +pub fn try_purge(force: bool) { + let expire = PURGE_EXPIRE.load(Ordering::Relaxed); + if expire == 0 { + return; + } + let now = options::clock_now(); + if !force && now < expire { + return; + } + PURGE_EXPIRE.store(0, Ordering::Relaxed); + for arena in arenas() { + let e = arena.purge_expire.load(Ordering::Relaxed); + if e != 0 && (force || now >= e) { + arena.purge_now(); + } + } +} + +/// fork() support: hold the reservation lock and every arena's claim lock +/// across the fork so the child's bitmaps are consistent. +#[cfg(not(windows))] +pub(crate) fn lock_for_fork() { + crate::sync::spin_acquire(&RESERVE_LOCK); + for arena in arenas() { + crate::sync::spin_acquire(&arena.claim_lock); + } +} + +#[cfg(not(windows))] +pub(crate) fn unlock_for_fork() { + for arena in arenas() { + crate::sync::spin_release(&arena.claim_lock); + } + crate::sync::spin_release(&RESERVE_LOCK); +} diff --git a/vendor/rimalloc/src/bins.rs b/vendor/rimalloc/src/bins.rs new file mode 100644 index 00000000..686732b7 --- /dev/null +++ b/vendor/rimalloc/src/bins.rs @@ -0,0 +1,136 @@ +//! Size-class ("bin") mapping, mirroring `page-queue.c`. +//! +//! Bins are spaced exponentially in 12.5% increments: sizes of at most 8 +//! words get an exact bin; above that the top three bits of the word size +//! select the bin. `MAX_ALIGN_SIZE == 16 == 2*PTR_SIZE` gives the `ALIGN2W` +//! variant: small sizes round up to double-word multiples. + +use crate::constants::*; + +/// Index of a heap page queue: `1..=BIN_HUGE` are size classes, `BIN_FULL` +/// is the queue of full pages. Constructed only through [`Bin::from_size`] +/// or the named constants, so indexing `[_; BIN_FULL + 1]` never re-checks. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct Bin(u8); + +impl Bin { + pub const HUGE: Bin = Bin(BIN_HUGE as u8); + pub const FULL: Bin = Bin(BIN_FULL as u8); + + /// `mi_bin`: the bin for a given byte size (huge if too large). + #[inline] + pub fn from_size(size: usize) -> Bin { + let mut wsize = wsize_from_size(size); + let bin = if wsize <= 8 { + // ALIGN2W: round to double-word sizes. + if wsize <= 1 { 1 } else { (wsize + 1) & !1 } + } else if wsize > MEDIUM_OBJ_WSIZE_MAX { + BIN_HUGE + } else { + wsize -= 1; + // Use the top 3 bits of the word size; adjust by 3 because the + // first 8 sizes get an exact bin. + let b = (usize::BITS - 1 - wsize.leading_zeros()) as usize; + let bin = ((b << 2) + ((wsize >> (b - 2)) & 0x03)) - 3; + debug_assert!(bin > 0 && bin < BIN_HUGE); + bin + }; + Bin(bin as u8) + } + + #[inline(always)] + pub const fn index(self) -> usize { + self.0 as usize + } + + /// Largest block size (in bytes) served by this bin (`_mi_bin_size`). + #[inline] + pub const fn block_size(self) -> usize { + BIN_WSIZE[self.0 as usize] * PTR_SIZE + } +} + +/// Largest word size per bin — the inverse of [`Bin::from_size`], matching +/// `MI_PAGE_QUEUES_EMPTY` in `init.c`. Entry 0 is a sentinel; the last two +/// entries are the huge and full queue markers. +pub const BIN_WSIZE: [usize; BIN_FULL + 1] = { + let mut t = [0usize; BIN_FULL + 1]; + t[0] = 1; + let mut bin = 1; + while bin <= 8 { + t[bin] = bin; + bin += 1; + } + while bin < BIN_HUGE { + let b = (bin + 3) >> 2; + let m = (bin + 3) & 3; + t[bin] = (1 << b) + ((m + 1) << (b - 2)); + bin += 1; + } + t[BIN_HUGE] = MEDIUM_OBJ_WSIZE_MAX + 1; // huge queue + t[BIN_FULL] = MEDIUM_OBJ_WSIZE_MAX + 2; // full queue + t +}; + +/// `mi_good_size`: the actual usable size class an allocation rounds to. +#[inline] +pub fn good_size(size: usize) -> usize { + if size <= MEDIUM_OBJ_SIZE_MAX { + Bin::from_size(size + PADDING_SIZE).block_size() + } else if size <= MAX_ALLOC_SIZE { + align_up(size + PADDING_SIZE, crate::os::page_size()) + } else { + size + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_matches_reference() { + // Spot-check against the table in init.c. + let expect: &[(usize, usize)] = &[ + (1, 1), + (8, 8), + (9, 10), + (16, 32), + (17, 40), + (24, 128), + (32, 512), + (40, 2048), + (48, 8192), + (56, 32768), + (64, 131072), + (72, 524288), + ]; + for &(bin, wsize) in expect { + assert_eq!(BIN_WSIZE[bin], wsize, "bin {bin}"); + } + } + + #[test] + fn bin_is_inverse_of_table() { + for bin in 1..BIN_HUGE { + let wsize = BIN_WSIZE[bin]; + if wsize > MEDIUM_OBJ_WSIZE_MAX { + continue; + } + let chosen = Bin::from_size(wsize * PTR_SIZE); + // ALIGN2W: odd word sizes <= 8 round up to the next even bin. + let expect = if wsize <= 8 && wsize > 1 { + (bin + 1) & !1 + } else { + bin + }; + assert_eq!(chosen.index(), expect, "max of bin {bin}"); + // The chosen bin must fit the size... + assert!(chosen.block_size() >= wsize * PTR_SIZE); + // ...and one word more must spill into a later bin. + assert!(Bin::from_size((wsize + 1) * PTR_SIZE).index() > expect.min(bin)); + } + assert_eq!(Bin::from_size(MEDIUM_OBJ_SIZE_MAX + 1), Bin::HUGE); + assert_eq!(Bin::from_size(0).index(), 1); + } +} diff --git a/vendor/rimalloc/src/bitmap.rs b/vendor/rimalloc/src/bitmap.rs new file mode 100644 index 00000000..b710a94a --- /dev/null +++ b/vendor/rimalloc/src/bitmap.rs @@ -0,0 +1,233 @@ +//! The arena claim/commit/purge bitmap protocol — the *single* +//! implementation, generic over the atomic word type. Production +//! (`arena.rs`) instantiates it with `core` atomics on the live `Arena` +//! bitmap fields (`blocks_inuse`/`blocks_committed`/`blocks_dirty`/ +//! `blocks_purge`); `tests/loom_arena.rs` instantiates the *same functions* +//! with loom atomics, so every CAS, ordering, and bit transition of the +//! real lock-free protocol is model-checked across all interleavings. +//! +//! The OS side effects (`commit`/`decommit`/`reset`) are abstracted as +//! closures, mirroring how `protocol.rs` abstracts block storage: production +//! supplies the real syscalls, loom supplies shadow-flag mutations (loom +//! cannot model real mmap). All bit twiddling, memory orderings, and the +//! claim/purge non-interference handshake live here and only here. + +use core::sync::atomic::Ordering; + +pub const FIELD_BITS: usize = usize::BITS as usize; + +/// Atomic-word interface implemented by `core` and loom `AtomicUsize`. +/// Richer than `protocol::AtomicWord` (adds `fetch_or`/`fetch_and`) because +/// the bitmaps set and clear bit ranges, not just CAS a packed word. +pub trait BitmapWord { + fn load(&self, order: Ordering) -> usize; + fn store(&self, val: usize, order: Ordering); + fn swap(&self, val: usize, order: Ordering) -> usize; + fn fetch_or(&self, val: usize, order: Ordering) -> usize; + fn fetch_and(&self, val: usize, order: Ordering) -> usize; + fn compare_exchange_weak( + &self, + current: usize, + new: usize, + success: Ordering, + failure: Ordering, + ) -> Result; +} + +impl BitmapWord for core::sync::atomic::AtomicUsize { + fn load(&self, order: Ordering) -> usize { + self.load(order) + } + fn store(&self, val: usize, order: Ordering) { + self.store(val, order) + } + fn swap(&self, val: usize, order: Ordering) -> usize { + self.swap(val, order) + } + fn fetch_or(&self, val: usize, order: Ordering) -> usize { + self.fetch_or(val, order) + } + fn fetch_and(&self, val: usize, order: Ordering) -> usize { + self.fetch_and(val, order) + } + fn compare_exchange_weak( + &self, + current: usize, + new: usize, + success: Ordering, + failure: Ordering, + ) -> Result { + self.compare_exchange_weak(current, new, success, failure) + } +} + +/// `1`-bits for `[bit, bit+count)` within one field. +#[inline] +pub fn field_mask(bit: usize, count: usize) -> usize { + debug_assert!(count >= 1 && bit + count <= FIELD_BITS); + if count == FIELD_BITS { + !0 + } else { + ((1usize << count) - 1) << bit + } +} + +/// Lock-free single-block claim within one field (the hot path's inner CAS +/// loop). `limit` is the number of valid blocks in this field (`<= +/// FIELD_BITS`). Returns the claimed bit index on success, or `None` if the +/// field is full. This is the lost-update / double-claim hazard: the CAS +/// guarantees no two threads ever take the same bit. +pub fn claim_single_field(inuse: &W, limit: usize) -> Option { + let mut cur = inuse.load(Ordering::Relaxed); + loop { + let avail = if limit == FIELD_BITS { + !0 + } else { + (1usize << limit) - 1 + }; + let free = !cur & avail; + if free == 0 { + return None; + } + let bit = free.trailing_zeros() as usize; + match inuse.compare_exchange_weak( + cur, + cur | (1 << bit), + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return Some(bit), + Err(v) => cur = v, + } + } +} + +/// Multi-block claim within one field, *under the caller's claim lock*: scan +/// for a `count`-wide free run and take it with a CAS. `limit` is the number +/// of valid blocks in this field. Returns the run's start bit on success. +/// The caller serializes these on the per-arena spinlock (excluding other +/// multi-block claims), but the lock-free single-block path and purge +/// race-modify the same field, so the take must be a CAS that re-validates +/// the run is *still* free — a blind `fetch_or` would co-claim a bit a +/// single-block CAS grabbed between the scan's load and the take. +pub fn claim_run_field(inuse: &W, limit: usize, count: usize) -> Option { + if limit < count { + return None; + } + let mut cur = inuse.load(Ordering::Relaxed); + let mut bit = 0; + while bit <= limit - count { + let mask = field_mask(bit, count); + if cur & mask != 0 { + bit += 1; + continue; + } + match inuse.compare_exchange_weak(cur, cur | mask, Ordering::AcqRel, Ordering::Relaxed) { + Ok(_) => return Some(bit), + // a racing single-block claim or purge moved the field: rescan + // from the start against the fresh value without advancing `bit`. + Err(v) => { + cur = v; + bit = 0; + } + } + } + None +} + +/// Post-claim commit bookkeeping for a `[bit, bit+count)` run in one field. +/// Sets the dirty and committed bits, clears any pending purge, and — when +/// the committed bit was *not* already set — invokes `commit` (the OS +/// recommit, or under loom the shadow-flag set). Returns `is_zero` (the +/// range was never dirtied). This is the commit-before-use guarantee: a +/// claimer that observes the committed bit clear MUST recommit before the +/// caller reads the memory. +pub fn prepare_run( + dirty: &W, + committed: &W, + purge: &W, + bit: usize, + count: usize, + commit: impl FnOnce(), +) -> bool { + let mask = field_mask(bit, count); + let was_dirty = dirty.fetch_or(mask, Ordering::AcqRel) & mask; + let was_committed = committed.fetch_or(mask, Ordering::AcqRel) & mask; + purge.fetch_and(!mask, Ordering::AcqRel); + if was_committed != mask { + commit(); + } + was_dirty == 0 +} + +/// Schedule a `[bit, bit+count)` run for purge and make it claimable again. +/// Sets the purge bits (so a later `purge_field` can decommit the still-free +/// run), then clears the inuse bits. The order matters: purge must be +/// visible before the blocks become claimable, so a re-claimer that sees the +/// committed bit set is the one responsible for the live commit state. +/// Returns the previous inuse word (the caller asserts the run was held). +pub fn release_run(inuse: &W, purge: &W, bit: usize, count: usize) -> usize { + let mask = field_mask(bit, count); + purge.fetch_or(mask, Ordering::AcqRel); + inuse.fetch_and(!mask, Ordering::AcqRel) +} + +/// Clear the committed bits for a `[bit, bit+count)` run (the cross-layer +/// fix in `arena::free`): a dying segment that decommitted parts of its +/// range marks the whole range uncommitted so the next claimer recommits it, +/// never handing out PROT_NONE memory. +pub fn clear_committed_run(committed: &W, bit: usize, count: usize) { + committed.fetch_and(!field_mask(bit, count), Ordering::AcqRel); +} + +/// Drive the decommit half of `purge_now` for one field. Takes the pending +/// purge word, restricts it to blocks still free (not reclaimed since), and +/// for each maximal free run in *decommit* mode hands it to `decommit_run`. +/// +/// `decommit_run(bit, len)` performs the non-interference handshake: take the +/// run out of circulation via `inuse.fetch_or` so a concurrent claimer that +/// saw the committed bits set cannot touch memory mid-decommit, clear the +/// committed bit, decommit (OS, or under loom clear the shadow flag), then +/// return the run to circulation. Runs reclaimed between the snapshot and the +/// `fetch_or` are skipped. `decommit_run` returns the run mask it actually +/// decommitted (0 if skipped). In *reset* mode the caller passes a +/// `decommit_run` that just resets without touching the bitmaps. +pub fn purge_field(purge: &W, inuse: &W, mut purge_run: impl FnMut(usize, usize)) { + let pending = purge.swap(0, Ordering::AcqRel); + if pending == 0 { + return; + } + let purgeable = pending & !inuse.load(Ordering::Acquire); + let mut m = purgeable; + while m != 0 { + let bit = m.trailing_zeros() as usize; + let mut run_len = 0; + while bit + run_len < FIELD_BITS && m & (1 << (bit + run_len)) != 0 { + run_len += 1; + } + purge_run(bit, run_len); + m &= !field_mask(bit, run_len); + } +} + +/// The decommit-mode handshake for a single run in `purge_field`: take it out +/// of circulation, and if it was still free, clear committed + decommit + +/// return it. `decommit(bit, len)` is the OS syscall (or loom shadow clear). +/// Returns `true` if the run was decommitted (vs. skipped because reclaimed). +pub fn decommit_run( + inuse: &W, + committed: &W, + bit: usize, + count: usize, + decommit: impl FnOnce(), +) -> bool { + let run = field_mask(bit, count); + if inuse.fetch_or(run, Ordering::AcqRel) & run == 0 { + committed.fetch_and(!run, Ordering::AcqRel); + decommit(); + inuse.fetch_and(!run, Ordering::AcqRel); + true + } else { + false + } +} diff --git a/vendor/rimalloc/src/constants.rs b/vendor/rimalloc/src/constants.rs new file mode 100644 index 00000000..412c872d --- /dev/null +++ b/vendor/rimalloc/src/constants.rs @@ -0,0 +1,114 @@ +//! Core size constants, mirroring `mimalloc/types.h` (64-bit values). + +pub const PTR_SIZE: usize = size_of::(); +pub const PTR_SHIFT: usize = PTR_SIZE.trailing_zeros() as usize; + +/// Minimal alignment necessary (`MI_MAX_ALIGN_SIZE`), 16 for SSE et al. +pub const MAX_ALIGN_SIZE: usize = 16; + +pub const SEGMENT_SLICE_SHIFT: usize = 13 + PTR_SHIFT; // 64 KiB on 64-bit +pub const SEGMENT_SHIFT: usize = if PTR_SIZE > 4 { + 9 + SEGMENT_SLICE_SHIFT // 32 MiB +} else { + 7 + SEGMENT_SLICE_SHIFT // 4 MiB on 32-bit +}; +pub const SMALL_PAGE_SHIFT: usize = SEGMENT_SLICE_SHIFT; // 64 KiB +pub const MEDIUM_PAGE_SHIFT: usize = 3 + SMALL_PAGE_SHIFT; // 512 KiB + +pub const SEGMENT_SIZE: usize = 1 << SEGMENT_SHIFT; +pub const SEGMENT_ALIGN: usize = SEGMENT_SIZE; +pub const SEGMENT_MASK: usize = SEGMENT_ALIGN - 1; +pub const SEGMENT_SLICE_SIZE: usize = 1 << SEGMENT_SLICE_SHIFT; +pub const SLICES_PER_SEGMENT: usize = SEGMENT_SIZE / SEGMENT_SLICE_SIZE; // 512 + +pub const SMALL_PAGE_SIZE: usize = 1 << SMALL_PAGE_SHIFT; +pub const MEDIUM_PAGE_SIZE: usize = 1 << MEDIUM_PAGE_SHIFT; + +pub const SMALL_OBJ_SIZE_MAX: usize = SMALL_PAGE_SIZE / 8; // 8 KiB +pub const MEDIUM_OBJ_SIZE_MAX: usize = MEDIUM_PAGE_SIZE / 8; // 64 KiB +pub const MEDIUM_OBJ_WSIZE_MAX: usize = MEDIUM_OBJ_SIZE_MAX / PTR_SIZE; +pub const LARGE_OBJ_SIZE_MAX: usize = SEGMENT_SIZE / 2; // 16 MiB + +/// `MI_SMALL_WSIZE_MAX`: max word-size served by the direct small-alloc path. +pub const SMALL_WSIZE_MAX: usize = 128; +pub const SMALL_SIZE_MAX: usize = SMALL_WSIZE_MAX * PTR_SIZE; // 1 KiB + +/// Blocks up to this size are guaranteed block-size aligned. +pub const MAX_ALIGN_GUARANTEE: usize = MEDIUM_OBJ_SIZE_MAX; +/// Alignments above this go into dedicated huge segments. +pub const BLOCK_ALIGNMENT_MAX: usize = SEGMENT_SIZE >> 1; +/// Maximum slice count for which interior pointers can find their page. +pub const MAX_SLICE_OFFSET_COUNT: usize = (BLOCK_ALIGNMENT_MAX / SEGMENT_SLICE_SIZE) - 1; + +/// `MI_MAX_ALLOC_SIZE`: largest request the allocator accepts. On 64-bit the +/// cap keeps a segment's slice count within 32 bits (mimalloc issue #877), +/// well below `isize::MAX`, so `good_size`'s page round-up cannot overflow. +pub const MAX_ALLOC_SIZE: usize = SEGMENT_SLICE_SIZE * (u32::MAX as usize - 1); + +// Commit mask: one bit per commit chunk (= one slice) of a segment. +pub const MINIMAL_COMMIT_SIZE: usize = SEGMENT_SLICE_SIZE; +pub const COMMIT_SIZE: usize = SEGMENT_SLICE_SIZE; // 64 KiB +pub const COMMIT_MASK_BITS: usize = SEGMENT_SIZE / COMMIT_SIZE; +pub const COMMIT_MASK_FIELD_BITS: usize = usize::BITS as usize; +pub const COMMIT_MASK_FIELD_COUNT: usize = COMMIT_MASK_BITS / COMMIT_MASK_FIELD_BITS; +const _: () = assert!(COMMIT_MASK_BITS == COMMIT_MASK_FIELD_COUNT * COMMIT_MASK_FIELD_BITS); + +/// `MI_SEGMENT_BIN_MAX == mi_segment_bin(MI_SLICES_PER_SEGMENT)`. +pub const SEGMENT_BIN_MAX: usize = 35; + +/// Number of exponential size bins (`MI_BIN_HUGE`), plus the full-queue bin. +pub const BIN_HUGE: usize = 73; +pub const BIN_FULL: usize = BIN_HUGE + 1; + +/// `MI_PAGES_DIRECT`: entries in the heap's direct small-page array. +pub const PAGES_DIRECT: usize = SMALL_WSIZE_MAX + PADDING_WSIZE + 1; + +/// Padding (`MI_PADDING`) is disabled in release parity builds. +pub const PADDING_SIZE: usize = 0; +pub const PADDING_WSIZE: usize = 0; + +/// Convert a byte size to a machine-word count, rounding up. +#[inline(always)] +pub const fn wsize_from_size(size: usize) -> usize { + size.div_ceil(PTR_SIZE) +} + +#[inline(always)] +pub const fn align_up(sz: usize, alignment: usize) -> usize { + debug_assert!(alignment != 0); + let mask = alignment - 1; + if alignment & mask == 0 { + (sz + mask) & !mask + } else { + ((sz + mask) / alignment) * alignment + } +} + +#[inline(always)] +pub const fn align_down(sz: usize, alignment: usize) -> usize { + debug_assert!(alignment != 0); + let mask = alignment - 1; + if alignment & mask == 0 { + sz & !mask + } else { + (sz / alignment) * alignment + } +} + +#[inline(always)] +pub const fn is_power_of_two(x: usize) -> bool { + x != 0 && x & (x - 1) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `MAX_ALLOC_SIZE` matches C v2.3.2's 64-bit value (issue #877) and stays + /// below `isize::MAX` so `good_size`'s page round-up cannot overflow. + #[test] + fn max_alloc_size_matches_c() { + assert_eq!(MAX_ALLOC_SIZE, SEGMENT_SLICE_SIZE * (u32::MAX as usize - 1)); + assert!(MAX_ALLOC_SIZE < isize::MAX as usize); + } +} diff --git a/vendor/rimalloc/src/free.rs b/vendor/rimalloc/src/free.rs new file mode 100644 index 00000000..a8d4b552 --- /dev/null +++ b/vendor/rimalloc/src/free.rs @@ -0,0 +1,245 @@ +//! The free path (port of `free.c`), including cross-thread frees that push +//! onto the page's atomic `xthread_free` list or the heap's delayed list. + +use core::ptr; +use core::sync::atomic::Ordering; + +use crate::page::{Block, Delayed, Page}; +use crate::page_ops; +use crate::segment::{Segment, SegmentKind, ptr_segment}; + +/// `mi_check_is_double_free` (`secure` feature): if the block's first word +/// decodes to a plausible pointer into the same page, confirm by walking +/// the page's free lists for an exact match. Reads one word of possibly +/// uninitialized user memory, so it is compiled out under Miri. +#[cfg(all(feature = "secure", not(miri)))] +fn check_is_double_free(page: &Page, block: *mut Block) -> bool { + // SAFETY: block points into the page's committed area. + let n = unsafe { page.block_next(block) }; + let start = page.page_start.get().addr(); + let end = start + page.capacity.get() as usize * page.block_size(); + let suspicious = n.addr() % size_of::() == 0 + && (n.is_null() || (n.addr() >= start && n.addr() < end)); + if !suspicious { + return false; + } + // confirm: is `block` actually on one of the free lists? + let on_list = |mut p: *mut Block| { + let mut count = page.capacity.get() as usize; + while !p.is_null() && count > 0 { + if p == block { + return true; + } + // SAFETY: list nodes are live blocks of this page. + unsafe { + p = page.block_next(p); + } + count -= 1; + } + false + }; + if on_list(page.free.get()) + || on_list(page.local_free.get()) + || on_list(page.thread_free().block()) + { + crate::error("double free detected"); + return true; + } + false +} + +#[cfg(not(all(feature = "secure", not(miri))))] +#[inline(always)] +fn check_is_double_free(_page: &Page, _block: *mut Block) -> bool { + false +} + +/// `mi_free_block_local`: thread-local free; push on `local_free`. +#[inline(always)] +fn free_block_local(page: &Page, block: *mut Block, check_full: bool) { + if check_is_double_free(page, block) { + return; + } + if cfg!(feature = "debug-fill") && !page.is_huge.get() { + // MI_DEBUG_FREED: stamp freed blocks to surface use-after-free. + // SAFETY: the block is dead; the next-pointer word is rewritten below. + unsafe { + core::ptr::write_bytes(block.cast::(), 0xDF, page.block_size()); + } + } + // SAFETY: the caller owns this (possibly partially-initialized) block; + // write-only, as the old contents may be uninitialized. + unsafe { page.block_set_next(block, page.local_free.get()) }; + page.local_free.set(block); + let used = page.used.get() - 1; + page.used.set(used); + if used == 0 { + page_ops::page_retire(page); + } else if check_full && page.in_full() { + page_ops::page_unfull(page); + } +} + +/// `_mi_page_ptr_unalign`: start of the block containing `p`. +pub fn page_ptr_unalign(page: &Page, p: *mut u8) -> *mut Block { + let diff = p.addr() - page.page_start.get().addr(); + let shift = page.block_size_shift.get(); + let adjust = if shift != 0 { + diff & ((1 << shift) - 1) + } else { + let recip = page.block_recip.get(); + if recip != 0 { + // Lemire fastmod, exact since `diff` and `block_size` are < 2^32 + // whenever the reciprocal is set (the page area of any page with + // a < 4 GiB block size is itself < 4 GiB). + let low = recip.wrapping_mul(diff as u64); + let adjust = ((low as u128 * page.block_size.get() as u128) >> 64) as usize; + debug_assert_eq!(adjust, diff % page.block_size.get()); + adjust + } else { + diff % page.block_size.get() + } + }; + p.map_addr(|a| a - adjust).cast() +} + +/// `mi_free_generic_local` +fn free_generic_local(page: &Page, p: *mut u8) { + let block = if page.has_aligned() { + page_ptr_unalign(page, p) + } else { + Block::at(p) + }; + free_block_local(page, block, true); +} + +/// `mi_free_block_delayed_mt`: push a foreign-owned block on the page's +/// thread-free list, or the owning heap's delayed list if this is the first +/// concurrent free into a full page. +fn free_block_delayed_mt(page: &Page, block: *mut Block) { + crate::protocol::mt_push( + &page.xthread_free, + || { + // The racy heap read is safe because FREEING is held. + let heap = page.xheap.load(Ordering::Acquire); + // SAFETY: heap stays valid while any page holds FREEING. + unsafe { + ptr::with_exposed_provenance::(heap) + .as_ref() + .map(|h| &h.thread_delayed_free) + } + }, + block.expose_provenance(), + |next| { + // SAFETY: we own the dying block; write-only (its contents may + // be uninitialized user memory). Page-keyed encoding for the + // page list; the heap-keyed delayed encoding is applied by the + // owner's decode in heap_delayed_free_partial symmetrically. + unsafe { + page.block_set_next(block, ptr::with_exposed_provenance_mut(next)); + } + }, + ); +} + +/// Abandoned-segment handling for a cross-thread free: try to reclaim the +/// segment into our heap so the free becomes local. Outlined so the hot +/// `free_block_mt` path pays no TLS access for this rare case. +#[cold] +#[inline(never)] +fn free_block_mt_reclaim(segment: &Segment, p: *mut u8) -> bool { + if !crate::init::thread_is_initialized() { + return false; + } + let heap = crate::init::heap_default(); + if crate::segment::attempt_reclaim(heap, segment) { + // now it is a local free in our heap + free(p); + true + } else { + false + } +} + +/// `mi_free_block_mt` +fn free_block_mt(page: &Page, segment: &Segment, block: *mut Block, p: *mut u8) { + // Try to reclaim an abandoned segment into our heap first (off by + // default, as in C mimalloc's `abandoned_reclaim_on_free`). + if segment.is_abandoned() + && crate::options::abandoned_reclaim_on_free() != 0 + && free_block_mt_reclaim(segment, p) + { + return; + } + if segment.kind.get() == SegmentKind::Huge { + crate::segment::huge_page_reset(segment, page, block); + } + free_block_delayed_mt(page, block); +} + +/// `mi_free_generic_mt` +#[inline(always)] +fn free_generic_mt(page: &Page, segment: &Segment, p: *mut u8) { + // don't check `has_aligned` to avoid a race (issue #865) + let block = page_ptr_unalign(page, p); + free_block_mt(page, segment, block, p); +} + +/// `mi_free` +#[inline(always)] +pub fn free(p: *mut u8) { + if p.is_null() || (p.addr() as isize) <= 0 { + return; + } + // SAFETY: a non-null `p` handed to free must come from our allocator, + // hence points into a live segment. + let segment = unsafe { ptr_segment(p) }; + let is_local = crate::init::thread_id() == segment.thread_id.load(Ordering::Relaxed); + let page = segment.page_of(p); + if is_local { + if !page.full_or_aligned() { + free_block_local(page, Block::at(p), false); + } else { + free_generic_local(page, p); + } + } else { + free_generic_mt(page, segment, p); + } +} + +/// `_mi_free_delayed_block`: returns false if contended. +pub fn free_delayed_block(block: *mut Block) -> bool { + // SAFETY: delayed blocks live in pages of segments owned by this thread. + let segment = unsafe { ptr_segment(block.cast()) }; + debug_assert!(crate::init::thread_id() == segment.thread_id.load(Ordering::Relaxed)); + let page = segment.page_of(block.cast()); + + // Re-enable delayed freeing before collecting, or blocks could get + // stranded on the page thread_free list with nothing on the heap list. + if !page_ops::page_try_use_delayed_free(page, Delayed::UseDelayedFree, false) { + return false; + } + page_ops::page_free_collect(page, false); + free_block_local(page, block, true); + true +} + +/// `mi_usable_size` +pub fn usable_size(p: *const u8) -> usize { + if p.is_null() { + return 0; + } + // SAFETY: p was allocated by us (API contract). + let segment = unsafe { ptr_segment(p) }; + let page = segment.page_of(p); + if !page.has_aligned() { + page.usable_block_size() + } else { + // adjust for interior aligned pointers + let block = page_ptr_unalign(page, p.cast_mut()); + let size = page.usable_block_size(); + let adjust = p.addr() - block.addr(); + debug_assert!(adjust <= size); + size - adjust + } +} diff --git a/vendor/rimalloc/src/heap.rs b/vendor/rimalloc/src/heap.rs new file mode 100644 index 00000000..1e545c66 --- /dev/null +++ b/vendor/rimalloc/src/heap.rs @@ -0,0 +1,326 @@ +//! Heaps (`mi_heap_t`): per-thread sets of page queues, one per size bin, +//! plus the `pages_free_direct` array that maps small word-sizes straight to +//! a page for the malloc fast path. + +use core::cell::Cell; +use core::ptr; +use core::sync::atomic::AtomicUsize; + +use crate::bins::{BIN_WSIZE, Bin}; +use crate::constants::*; +use crate::page::Page; +use crate::segment::SegmentsTld; + +/// `mi_page_queue_t`: doubly-linked queue of pages of one block size. +pub struct PageQueue { + pub first: Cell<*mut Page>, + pub last: Cell<*mut Page>, + pub block_size: usize, +} + +impl PageQueue { + /// `mi_page_queue_is_huge` + #[inline] + pub fn is_huge(&self) -> bool { + self.block_size == (MEDIUM_OBJ_SIZE_MAX + PTR_SIZE) + } + /// `mi_page_queue_is_full` + #[inline] + pub fn is_full(&self) -> bool { + self.block_size == (MEDIUM_OBJ_SIZE_MAX + 2 * PTR_SIZE) + } + /// `mi_page_queue_is_special` + #[inline] + pub fn is_special(&self) -> bool { + self.block_size > MEDIUM_OBJ_SIZE_MAX + } +} + +/// Thread-local data (`mi_tld_t`). +pub struct Tld { + pub heartbeat: Cell, + pub recurse: Cell, + pub heap_backing: Cell<*mut Heap>, + pub heaps: Cell<*mut Heap>, // list of this thread's heaps + pub segments: SegmentsTld, +} + +impl Tld { + pub fn new() -> Tld { + Tld { + heartbeat: Cell::new(0), + recurse: Cell::new(false), + heap_backing: Cell::new(ptr::null_mut()), + heaps: Cell::new(ptr::null_mut()), + segments: SegmentsTld::new(), + } + } +} + +/// `mi_heap_t`. Shared across threads only through `thread_delayed_free` +/// (atomic); everything else is owner-thread-only. +#[repr(C)] +pub struct Heap { + pub tld: Cell<*mut Tld>, + /// Exposed-address head of the delayed-free list (`*mut Block`). + pub thread_delayed_free: AtomicUsize, + pub thread_id: Cell, + pub cookie: Cell, + pub keys: [Cell; 2], + pub page_count: Cell, + pub page_retired_min: Cell, + pub page_retired_max: Cell, + pub pages_full_size: Cell, + pub generic_count: Cell, + pub generic_collect_count: Cell, + pub next: Cell<*mut Heap>, + pub no_reclaim: Cell, + pub tag: Cell, + /// Preferred/required arena (`mi_heap_t.arena_id`; 0 = any). + pub arena_id: Cell, + pub pages_free_direct: [Cell<*mut Page>; PAGES_DIRECT], + pub pages: [PageQueue; BIN_FULL + 1], +} + +// SAFETY: cross-thread access is restricted to `thread_delayed_free` and the +// immutable identity fields, mirroring mimalloc's discipline. +unsafe impl Sync for Heap {} + +/// The static empty page that `pages_free_direct` points at when a bin has +/// no page: its `free` list is null so the fast path falls into the generic +/// path without a branch. +pub static EMPTY_PAGE: Page = Page::EMPTY; + +#[inline(always)] +pub fn empty_page_ptr() -> *mut Page { + ptr::from_ref(&EMPTY_PAGE).cast_mut() +} + +impl Heap { + /// An uninitialized heap with properly sized queues (`_mi_heap_empty`). + pub fn new_empty() -> Heap { + Heap { + tld: Cell::new(ptr::null_mut()), + thread_delayed_free: AtomicUsize::new(0), + thread_id: Cell::new(0), + cookie: Cell::new(0), + keys: [Cell::new(0), Cell::new(0)], + page_count: Cell::new(0), + page_retired_min: Cell::new(BIN_FULL), + page_retired_max: Cell::new(0), + pages_full_size: Cell::new(0), + generic_count: Cell::new(0), + generic_collect_count: Cell::new(0), + next: Cell::new(ptr::null_mut()), + no_reclaim: Cell::new(false), + tag: Cell::new(0), + arena_id: Cell::new(0), + pages_free_direct: [const { Cell::new(ptr::null_mut()) }; PAGES_DIRECT], + pages: core::array::from_fn(|bin| PageQueue { + first: Cell::new(ptr::null_mut()), + last: Cell::new(ptr::null_mut()), + block_size: BIN_WSIZE[bin] * PTR_SIZE, + }), + } + } + + pub fn init_direct_pages(&self) { + for slot in &self.pages_free_direct { + slot.set(empty_page_ptr()); + } + } + + #[inline(always)] + pub fn segments_tld(&self) -> &SegmentsTld { + // SAFETY: tld outlives its heaps. + unsafe { &(*self.tld.get()).segments } + } + + #[inline(always)] + pub fn is_initialized(&self) -> bool { + !self.tld.get().is_null() + } + + /// `mi_page_queue` (by size). + #[inline(always)] + pub fn page_queue(&self, size: usize) -> &PageQueue { + &self.pages[Bin::from_size(size).index()] + } + + /// `mi_page_bin` + `mi_heap_page_queue_of`. + pub fn page_queue_of(&self, page: &Page) -> &PageQueue { + let bin = if page.in_full() { + Bin::FULL + } else if page.is_huge.get() { + Bin::HUGE + } else { + Bin::from_size(page.block_size()) + }; + &self.pages[bin.index()] + } + + /// `mi_page_set_in_full` (maintains `pages_full_size`). + pub fn page_set_in_full(&self, page: &Page, in_full: bool) { + if page.in_full() != in_full { + let size = page.capacity.get() as usize * page.block_size(); + if in_full { + self.pages_full_size.set(self.pages_full_size.get() + size); + } else { + self.pages_full_size.set(self.pages_full_size.get() - size); + } + } + page.set_in_full(in_full); + } + + /// `mi_heap_queue_first_update`: keep `pages_free_direct` in sync after + /// the first page of a small queue changed. + pub fn queue_first_update(&self, pq: &PageQueue) { + let size = pq.block_size; + if size > SMALL_SIZE_MAX { + return; + } + let mut page = pq.first.get(); + if page.is_null() { + page = empty_page_ptr(); + } + let idx = wsize_from_size(size); + if self.pages_free_direct[idx].get() == page { + return; + } + let start = if idx <= 1 { + 0 + } else { + // Skip back over queues that map to the same bin. + let bin = Bin::from_size(size); + let pq_index = + (ptr::from_ref(pq).addr() - self.pages.as_ptr().addr()) / size_of::(); + let mut prev = pq_index - 1; + while prev > 0 && bin == Bin::from_size(self.pages[prev].block_size) { + prev -= 1; + } + (1 + wsize_from_size(self.pages[prev].block_size)).min(idx) + }; + for slot in &self.pages_free_direct[start..=idx] { + slot.set(page); + } + } + + /// `mi_page_queue_remove` + pub fn page_queue_remove(&self, pq: &PageQueue, page: &Page) { + // SAFETY: queue nodes are live pages of this heap. + unsafe { + if let Some(prev) = page.prev.get().as_ref() { + prev.next.set(page.next.get()); + } + if let Some(next) = page.next.get().as_ref() { + next.prev.set(page.prev.get()); + } + } + let page_ptr = ptr::from_ref(page).cast_mut(); + if page_ptr == pq.last.get() { + pq.last.set(page.prev.get()); + } + if page_ptr == pq.first.get() { + pq.first.set(page.next.get()); + self.queue_first_update(pq); + } + self.page_count.set(self.page_count.get() - 1); + page.next.set(ptr::null_mut()); + page.prev.set(ptr::null_mut()); + self.page_set_in_full(page, false); + } + + /// `mi_page_queue_push` + pub fn page_queue_push(&self, pq: &PageQueue, page: &Page) { + let page_ptr = ptr::from_ref(page).cast_mut(); + self.page_set_in_full(page, pq.is_full()); + page.next.set(pq.first.get()); + page.prev.set(ptr::null_mut()); + // SAFETY: queue nodes are live pages of this heap. + match unsafe { pq.first.get().as_ref() } { + Some(first) => { + first.prev.set(page_ptr); + pq.first.set(page_ptr); + } + None => { + pq.first.set(page_ptr); + pq.last.set(page_ptr); + } + } + self.queue_first_update(pq); + self.page_count.set(self.page_count.get() + 1); + } + + /// `mi_page_queue_move_to_front` + pub fn page_queue_move_to_front(&self, pq: &PageQueue, page: &Page) { + if pq.first.get() == ptr::from_ref(page).cast_mut() { + return; + } + self.page_queue_remove(pq, page); + self.page_queue_push(pq, page); + } + + /// `mi_page_queue_enqueue_from` (always at the end, like mimalloc). + pub fn page_queue_enqueue_from(&self, to: &PageQueue, from: &PageQueue, page: &Page) { + let page_ptr = ptr::from_ref(page).cast_mut(); + // delete from `from` + // SAFETY: queue nodes are live pages of this heap. + unsafe { + if let Some(prev) = page.prev.get().as_ref() { + prev.next.set(page.next.get()); + } + if let Some(next) = page.next.get().as_ref() { + next.prev.set(page.prev.get()); + } + } + if page_ptr == from.last.get() { + from.last.set(page.prev.get()); + } + if page_ptr == from.first.get() { + from.first.set(page.next.get()); + self.queue_first_update(from); + } + // insert at the end of `to` + page.prev.set(to.last.get()); + page.next.set(ptr::null_mut()); + // SAFETY: queue nodes are live pages of this heap. + match unsafe { to.last.get().as_ref() } { + Some(last) => { + last.next.set(page_ptr); + to.last.set(page_ptr); + } + None => { + to.first.set(page_ptr); + to.last.set(page_ptr); + self.queue_first_update(to); + } + } + self.page_set_in_full(page, to.is_full()); + } + + /// The heap on this thread with the given tag (`_mi_heap_by_tag`). + pub fn by_tag(&self, tag: u8) -> Option<&Heap> { + if self.tag.get() == tag { + return Some(self); + } + // SAFETY: heaps list nodes are live heaps of this thread. + unsafe { + let mut h = (*self.tld.get()).heaps.get(); + while let Some(heap) = h.as_ref() { + if heap.tag.get() == tag { + return Some(heap); + } + h = heap.next.get(); + } + } + None + } +} + +/// Direct-fit page for very small sizes (`_mi_heap_get_free_small_page`). +#[inline(always)] +pub fn heap_get_free_small_page(heap: &Heap, size: usize) -> *mut Page { + let idx = wsize_from_size(size); + debug_assert!(idx < PAGES_DIRECT); + heap.pages_free_direct[idx].get() +} diff --git a/vendor/rimalloc/src/heap_ops.rs b/vendor/rimalloc/src/heap_ops.rs new file mode 100644 index 00000000..f65c9338 --- /dev/null +++ b/vendor/rimalloc/src/heap_ops.rs @@ -0,0 +1,281 @@ +//! Heap lifetime operations (port of `heap.c`): collect, delete, destroy. + +use core::ptr; +use core::sync::atomic::Ordering; + +use crate::constants::*; +use crate::heap::{Heap, PageQueue}; +use crate::page::{Delayed, Page}; +use crate::page_ops; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Collect { + Normal, + Force, + Abandon, +} + +/// `mi_heap_visit_pages`: visit all pages (the visitor may free the page). +/// Returns false if the visitor broke out. +pub fn heap_visit_pages(heap: &Heap, mut f: impl FnMut(&Heap, &PageQueue, &Page) -> bool) -> bool { + if heap.page_count.get() == 0 { + return true; + } + for pq in &heap.pages { + let mut p = pq.first.get(); + // SAFETY: queue nodes are live pages; we read `next` before visiting + // since the visitor may free the page. + while let Some(page) = unsafe { p.as_ref() } { + let next = page.next.get(); + if !f(heap, pq, page) { + return false; + } + p = next; + } + } + true +} + +/// `mi_heap_collect_ex` +pub fn heap_collect_ex(heap: &Heap, collect: Collect) { + if !heap.is_initialized() { + return; + } + let force = collect >= Collect::Force; + crate::hooks::deferred_free(heap, force); + + // The backing heap reclaims all abandoned segments on a forced collect + // (end-of-program): if all memory is freed by now, all segments free too. + if collect == Collect::Force && heap_is_backing(heap) && !heap.no_reclaim.get() { + crate::segment::reclaim_all(heap, heap.segments_tld()); + } + + // If abandoning, mark all pages to no longer add to delayed_free. + if collect == Collect::Abandon { + heap_visit_pages(heap, |_, _, page| { + page_ops::page_use_delayed_free(page, Delayed::NeverDelayedFree, false); + true + }); + } + + // Free all current thread delayed blocks (after this, no more + // thread-delayed references into our pages if abandoning). + page_ops::heap_delayed_free_all(heap); + + // Collect retired pages. + page_ops::heap_collect_retired(heap, force); + + // Collect all pages owned by this thread. + heap_visit_pages(heap, |_, pq, page| { + page_ops::page_free_collect(page, collect >= Collect::Force); + if collect == Collect::Force { + page.segment().try_purge(true); + } + if page.all_free() { + page_ops::page_free(page, pq, collect >= Collect::Force); + } else if collect == Collect::Abandon { + page_ops::page_abandon(page, pq); + } + true + }); + + // Collect abandoned segments. + crate::segment::abandoned_collect(heap, collect == Collect::Force, heap.segments_tld()); +} + +pub fn heap_collect(heap: &Heap, force: bool) { + heap_collect_ex( + heap, + if force { + Collect::Force + } else { + Collect::Normal + }, + ); +} + +pub fn heap_collect_abandon(heap: &Heap) { + heap_collect_ex(heap, Collect::Abandon); +} + +/// `mi_heap_reset_pages` +fn heap_reset_pages(heap: &Heap) { + heap.init_direct_pages(); + for pq in &heap.pages { + pq.first.set(ptr::null_mut()); + pq.last.set(ptr::null_mut()); + } + heap.page_count.set(0); + heap.page_retired_min.set(BIN_FULL); + heap.page_retired_max.set(0); + heap.pages_full_size.set(0); +} + +/// `mi_heap_free`: free the heap struct itself (allocated on the backing heap). +fn heap_free(heap: &Heap) { + if heap_is_backing(heap) { + return; // dont free the backing heap + } + // Remove from the thread-local heaps list. + // SAFETY: tld and the heaps list are owned by this thread. + unsafe { + let tld = &*heap.tld.get(); + let mut prev: *mut Heap = ptr::null_mut(); + let mut curr = tld.heaps.get(); + while !curr.is_null() && curr != ptr::from_ref(heap).cast_mut() { + prev = curr; + curr = (*curr).next.get(); + } + if !curr.is_null() { + match prev.as_ref() { + Some(prev) => prev.next.set((*curr).next.get()), + None => tld.heaps.set((*curr).next.get()), + } + } + } + heap.tld.set(ptr::null_mut()); + crate::free::free(ptr::from_ref(heap).cast_mut().cast()); +} + +pub fn heap_is_backing(heap: &Heap) -> bool { + // SAFETY: tld is owned by this thread. + unsafe { (*heap.tld.get()).heap_backing.get() == ptr::from_ref(heap).cast_mut() } +} + +/// `_mi_heap_init` for a fresh (non-backing) heap. +pub fn heap_init( + heap: &Heap, + tld: *mut crate::heap::Tld, + no_reclaim: bool, + tag: u8, + arena_id: usize, +) { + heap.tld.set(tld); + heap.thread_id.set(crate::init::thread_id()); + heap.cookie.set(crate::init::random_next() | 1); + heap.keys[0].set(crate::init::random_next()); + heap.keys[1].set(crate::init::random_next()); + heap.no_reclaim.set(no_reclaim); + heap.tag.set(tag); + heap.arena_id.set(arena_id); + heap.init_direct_pages(); + // push on the thread-local heaps list + // SAFETY: tld is owned by this thread. + unsafe { + heap.next.set((*tld).heaps.get()); + (*tld).heaps.set(ptr::from_ref(heap).cast_mut()); + } +} + +/// `mi_heap_new_ex` +pub fn heap_new(allow_destroy: bool, tag: u8, arena_id: usize) -> Option<&'static Heap> { + let bheap = crate::init::heap_default(); + let p = crate::alloc::heap_malloc_zero(bheap, size_of::(), false)?; + let heap_ptr = p.as_ptr().cast::(); + // SAFETY: freshly allocated, sized and aligned for a Heap. + unsafe { heap_ptr.write(Heap::new_empty()) }; + // SAFETY: just initialized above. + let heap = unsafe { &*heap_ptr }; + heap_init(heap, bheap.tld.get(), allow_destroy, tag, arena_id); + Some(heap) +} + +/// `_mi_heap_page_destroy` +fn heap_page_destroy(heap: &Heap, page: &Page) { + page_ops::page_use_delayed_free(page, Delayed::NeverDelayedFree, false); + // pretend it is all free now + page.used.set(0); + page.next.set(ptr::null_mut()); + page.prev.set(ptr::null_mut()); + page.set_heap(ptr::null_mut()); + page.segment().page_free(page, false, heap.segments_tld()); +} + +/// `_mi_heap_destroy_pages` +pub fn heap_destroy_pages(heap: &Heap) { + heap_visit_pages(heap, |heap, _, page| { + heap_page_destroy(heap, page); + true + }); + heap_reset_pages(heap); +} + +/// `mi_heap_destroy`: free all pages without freeing individual blocks. +pub fn heap_destroy(heap: &Heap) { + if !heap.is_initialized() { + return; + } + if !heap.no_reclaim.get() { + // unsafe to destroy a heap that may contain reclaimed pages + heap_delete(heap); + } else { + heap_destroy_pages(heap); + heap_free(heap); + } +} + +/// `mi_heap_absorb`: move all pages of `from` into `heap`. +fn heap_absorb(heap: &Heap, from: &Heap) { + if from.page_count.get() == 0 { + return; + } + page_ops::heap_delayed_free_partial(from); + for (pq, append) in heap.pages.iter().zip(&from.pages) { + // set new heap field on every page; spins out DELAYED_FREEING + let mut p = append.first.get(); + let mut count = 0; + // SAFETY: queue nodes are live pages of `from`. + while let Some(page) = unsafe { p.as_ref() } { + page.xheap + .store(ptr::from_ref(heap).expose_provenance(), Ordering::Release); + page_ops::page_use_delayed_free(page, Delayed::UseDelayedFree, false); + if page.in_full() { + // transfer the full-queue size accounting along with the page + // (`from`'s counter is wiped by heap_reset_pages below) + let size = page.capacity.get() as usize * page.block_size(); + heap.pages_full_size.set(heap.pages_full_size.get() + size); + } + count += 1; + p = page.next.get(); + } + // append the queue + if append.first.get().is_null() { + continue; + } + // SAFETY: live pages. + unsafe { + if pq.last.get().is_null() { + pq.first.set(append.first.get()); + pq.last.set(append.last.get()); + heap.queue_first_update(pq); + } else { + (*pq.last.get()).next.set(append.first.get()); + (*append.first.get()).prev.set(pq.last.get()); + pq.last.set(append.last.get()); + } + } + append.first.set(ptr::null_mut()); + append.last.set(ptr::null_mut()); + heap.page_count.set(heap.page_count.get() + count); + from.page_count.set(from.page_count.get() - count); + } + debug_assert!(from.page_count.get() == 0); + page_ops::heap_delayed_free_all(from); + heap_reset_pages(from); +} + +/// `mi_heap_delete`: delete a heap, transferring live pages to the backing +/// heap (or abandoning them for a backing heap). +pub fn heap_delete(heap: &Heap) { + if !heap.is_initialized() { + return; + } + // SAFETY: tld is owned by this thread. + let bheap = unsafe { &*(*heap.tld.get()).heap_backing.get() }; + if !ptr::eq(bheap, heap) && bheap.tag.get() == heap.tag.get() { + heap_absorb(bheap, heap); + } else { + heap_collect_abandon(heap); + } + heap_free(heap); +} diff --git a/vendor/rimalloc/src/hooks.rs b/vendor/rimalloc/src/hooks.rs new file mode 100644 index 00000000..8948528a --- /dev/null +++ b/vendor/rimalloc/src/hooks.rs @@ -0,0 +1,127 @@ +//! User-registered callbacks (`mi_register_deferred_free`, +//! `mi_register_output`, `mi_register_error`). + +use core::ffi::c_void; +use core::sync::atomic::{AtomicPtr, Ordering}; + +/// `mi_deferred_free_fun`: called when the allocator runs low on free +/// blocks, so the program can free cached objects. `force` is set on +/// forced collection; `heartbeat` is a deterministic monotonic count. +pub type DeferredFreeFun = extern "C" fn(force: bool, heartbeat: u64, arg: *mut c_void); +/// `mi_output_fun`: receives diagnostic output (stats, warnings). +pub type OutputFun = extern "C" fn(msg: *const u8, len: usize, arg: *mut c_void); +/// `mi_error_fun`: receives error codes (EFAULT, ENOMEM, EOVERFLOW, EINVAL). +pub type ErrorFun = extern "C" fn(code: i32, arg: *mut c_void); + +// Function pointers stored as data pointers (provenance preserved). +static DEFERRED_FREE: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); +static DEFERRED_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +static OUTPUT: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); +static OUTPUT_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +static ERROR: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); +static ERROR_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); + +/// `mi_register_deferred_free` (pass `None` to unregister). +pub fn register_deferred_free(f: Option, arg: *mut c_void) { + DEFERRED_ARG.store(arg, Ordering::Release); + DEFERRED_FREE.store( + f.map_or(core::ptr::null_mut(), |f| f as *mut ()), + Ordering::Release, + ); +} + +/// `mi_register_output` +pub fn register_output(f: Option, arg: *mut c_void) { + OUTPUT_ARG.store(arg, Ordering::Release); + OUTPUT.store( + f.map_or(core::ptr::null_mut(), |f| f as *mut ()), + Ordering::Release, + ); +} + +/// `mi_register_error` +pub fn register_error(f: Option, arg: *mut c_void) { + ERROR_ARG.store(arg, Ordering::Release); + ERROR.store( + f.map_or(core::ptr::null_mut(), |f| f as *mut ()), + Ordering::Release, + ); +} + +/// `_mi_deferred_free`: bump the heartbeat and call the user hook (guarded +/// against recursion through `tld.recurse`). +pub fn deferred_free(heap: &crate::heap::Heap, force: bool) { + // SAFETY: tld outlives its heaps; owner-thread access. + let tld = unsafe { &*heap.tld.get() }; + tld.heartbeat.set(tld.heartbeat.get() + 1); + let f = DEFERRED_FREE.load(Ordering::Acquire); + if !f.is_null() && !tld.recurse.get() { + tld.recurse.set(true); + // SAFETY: registered via register_deferred_free with this signature. + let f: DeferredFreeFun = unsafe { core::mem::transmute::<*mut (), DeferredFreeFun>(f) }; + f( + force, + tld.heartbeat.get(), + DEFERRED_ARG.load(Ordering::Acquire), + ); + tld.recurse.set(false); + } +} + +/// Route a diagnostic message to the registered output hook (or stderr). +pub fn output(msg: &str) { + let f = OUTPUT.load(Ordering::Acquire); + if !f.is_null() { + // SAFETY: registered via register_output with this signature. + let f: OutputFun = unsafe { core::mem::transmute::<*mut (), OutputFun>(f) }; + f(msg.as_ptr(), msg.len(), OUTPUT_ARG.load(Ordering::Acquire)); + } else { + #[cfg(not(windows))] + // SAFETY: plain write(2) to stderr. + unsafe { libc::write(2, msg.as_ptr().cast(), msg.len()) }; + #[cfg(windows)] + // SAFETY: WriteFile to stderr handle. + unsafe { + const STD_ERROR_HANDLE: u32 = u32::MAX - 12; // -12 + let handle = GetStdHandle(STD_ERROR_HANDLE); + if !handle.is_null() && handle != winapi_invalid_handle() { + let mut written: u32 = 0; + WriteFile( + handle, + msg.as_ptr().cast(), + msg.len().try_into().unwrap_or(u32::MAX), + &mut written, + core::ptr::null_mut(), + ); + } + } + } +} + +#[cfg(windows)] +unsafe extern "system" { + fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void; + fn WriteFile( + hFile: *mut core::ffi::c_void, + lpBuffer: *const core::ffi::c_void, + nNumberOfBytesToWrite: u32, + lpNumberOfBytesWritten: *mut u32, + lpOverlapped: *mut core::ffi::c_void, + ) -> i32; +} + +#[cfg(windows)] +fn winapi_invalid_handle() -> *mut core::ffi::c_void { + core::ptr::with_exposed_provenance_mut(!0isize as usize) // INVALID_HANDLE_VALUE +} + +/// Report an error code to the registered error hook (after the message +/// went to the output hook). +pub fn error(code: i32) { + let f = ERROR.load(Ordering::Acquire); + if !f.is_null() { + // SAFETY: registered via register_error with this signature. + let f: ErrorFun = unsafe { core::mem::transmute::<*mut (), ErrorFun>(f) }; + f(code, ERROR_ARG.load(Ordering::Acquire)); + } +} diff --git a/vendor/rimalloc/src/init.rs b/vendor/rimalloc/src/init.rs new file mode 100644 index 00000000..f63366d8 --- /dev/null +++ b/vendor/rimalloc/src/init.rs @@ -0,0 +1,624 @@ +//! Process and thread initialization (port of `init.c`): the thread-local +//! default heap, lazy thread setup, and thread-exit cleanup via a pthread +//! key destructor (Unix) or FLS callback (Windows). + +use core::cell::Cell; +use core::ptr; +use core::sync::atomic::{AtomicUsize, Ordering}; + +use crate::heap::{Heap, Tld}; +use crate::os::OsMem; + +/// Unique, cheap thread id. `pthread_self` rather than a thread-local's +/// address: macOS tears down and re-instantiates `#[thread_local]` storage +/// before pthread TSD destructors run, so a TLS address changes identity +/// mid-teardown and exit-time frees would take the cross-thread path +/// (leaking blocks onto the dying heap's delayed list). +/// +/// On Apple arm64 this reads pthread TSD slot 0 (the pthread self pointer) +/// straight off the thread register, like C mimalloc's +/// `_mi_prim_thread_id`; it stays valid through TLS teardown. +#[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] +#[inline(always)] +pub fn thread_id() -> usize { + let tcb: *const usize; + // SAFETY: reads the read-only thread register; no memory effects. + unsafe { + core::arch::asm!( + "mrs {tcb}, tpidrro_el0", + "bic {tcb}, {tcb}, #7", + tcb = out(reg) tcb, + options(nomem, nostack, pure, preserves_flags) + ); + } + // SAFETY: slot 0 of the TSD block is `pthread_self`, always readable. + unsafe { *tcb } +} + +#[cfg(all(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))), not(windows)))] +#[inline(always)] +pub fn thread_id() -> usize { + let t = tls::TID.get(); + if t != 0 { + return t; + } + // SAFETY: always callable; on macOS this is a thread-register read. + let t = unsafe { libc::pthread_self() as usize }; + tls::TID.set(t); + t +} + +#[cfg(windows)] +#[inline(always)] +pub fn thread_id() -> usize { + let t = tls::TID.get(); + if t != 0 { + return t; + } + // SAFETY: always callable. + let t = unsafe { GetCurrentThreadId() as usize }; + tls::TID.set(t); + t +} + +// The static empty heap: the initial default for every thread, so the +// malloc fast path needs no init check (its pages have no free blocks, so +// allocation falls into `malloc_generic` which initializes the thread). +struct StaticHeap(Heap); +// SAFETY: read-only use across threads (its lists stay empty), except for +// the main-thread heap which is owned by the main thread. +unsafe impl Sync for StaticHeap {} + +static EMPTY_HEAP: StaticHeap = StaticHeap(heap_empty_const( + ptr::from_ref(&crate::heap::EMPTY_PAGE).cast_mut(), +)); + +const fn heap_empty_const(empty_page: *mut crate::page::Page) -> Heap { + use crate::bins::BIN_WSIZE; + use crate::constants::{BIN_FULL, PAGES_DIRECT, PTR_SIZE}; + use crate::heap::PageQueue; + let mut pages = [const { + PageQueue { + first: Cell::new(ptr::null_mut()), + last: Cell::new(ptr::null_mut()), + block_size: 0, + } + }; BIN_FULL + 1]; + let mut i = 0; + while i <= BIN_FULL { + pages[i].block_size = BIN_WSIZE[i] * PTR_SIZE; + i += 1; + } + Heap { + tld: Cell::new(ptr::null_mut()), + thread_delayed_free: core::sync::atomic::AtomicUsize::new(0), + thread_id: Cell::new(0), + cookie: Cell::new(0), + keys: [Cell::new(0), Cell::new(0)], + page_count: Cell::new(0), + page_retired_min: Cell::new(BIN_FULL), + page_retired_max: Cell::new(0), + pages_full_size: Cell::new(0), + generic_count: Cell::new(0), + generic_collect_count: Cell::new(0), + next: Cell::new(ptr::null_mut()), + no_reclaim: Cell::new(false), + tag: Cell::new(0), + arena_id: Cell::new(0), + // SAFETY: Cell is repr(transparent) over T. + pages_free_direct: unsafe { + core::mem::transmute::< + [*mut crate::page::Page; PAGES_DIRECT], + [Cell<*mut crate::page::Page>; PAGES_DIRECT], + >([empty_page; PAGES_DIRECT]) + }, + pages, + } +} + +#[inline(always)] +fn empty_heap() -> *mut Heap { + ptr::from_ref(&EMPTY_HEAP.0).cast_mut() +} + +/// Direct pthread TSD slot for the default heap on Apple arm64: the same +/// unused slot C mimalloc claims (`MI_TLS_SLOT` 89, `__PTK_FRAMEWORK_OLDGC_KEY9`). +/// Mach-O `thread_local!`/`#[thread_local]` accesses go through a +/// `_tlv_get_addr` call on every malloc/free; this is a 3-instruction load +/// off the read-only thread register instead, and (unlike TLV storage) it +/// stays valid through TLS teardown at thread exit. +#[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] +mod tsd { + use super::*; + + const HEAP_SLOT: usize = 89; + + #[inline(always)] + fn tsd_base() -> *mut usize { + let tcb: *mut usize; + // SAFETY: reads the read-only thread register; no memory effects. + unsafe { + core::arch::asm!( + "mrs {tcb}, tpidrro_el0", + "bic {tcb}, {tcb}, #7", + tcb = out(reg) tcb, + options(nomem, nostack, pure, preserves_flags) + ); + } + tcb + } + + /// Relaxed atomics rather than plain loads/stores: the slot is + /// logically thread-private, but it lives inside the pthread struct, + /// which libpthread recycles across threads at the same address (reuse + /// is ordered inside libpthread, invisibly to TSan). The dying thread's + /// final store (`thread_done` via the TSD destructor) would otherwise + /// appear to race the first read of a later thread occupying the + /// recycled struct. Same single ldr/str on arm64; zero cost. + pub struct HeapSlot; + impl HeapSlot { + #[inline(always)] + pub fn get(&self) -> *mut Heap { + // SAFETY: slot 89 of this thread's TSD block is reserved for the + // allocator (as in C mimalloc on Apple); it is zero on fresh + // threads, which maps to the static empty heap. + let slot = unsafe { AtomicUsize::from_ptr(tsd_base().add(HEAP_SLOT)) }; + let p = slot.load(Ordering::Relaxed) as *mut Heap; + if p.is_null() { empty_heap() } else { p } + } + #[inline(always)] + pub fn set(&self, v: *mut Heap) { + // SAFETY: as in `get`; only this thread writes its own slot. + let slot = unsafe { AtomicUsize::from_ptr(tsd_base().add(HEAP_SLOT)) }; + slot.store(v as usize, Ordering::Relaxed); + } + } + pub static DEFAULT_HEAP: HeapSlot = HeapSlot; +} + +/// The allocator's three thread-local slots. Stable Rust uses +/// const-initialized `thread_local!` (correct everywhere, ~1ns per access +/// through `LocalKey`); the opt-in `nightly` feature uses raw +/// `#[thread_local]` statics for direct TLS loads on the malloc/free fast +/// paths. On Apple arm64 the default-heap slot instead lives in a pthread +/// TSD slot (see `tsd` above). +#[cfg(not(feature = "nightly"))] +mod tls { + use super::*; + + pub struct Slot(&'static std::thread::LocalKey>); + impl Slot { + #[inline(always)] + pub fn get(&self) -> T { + self.0.with(Cell::get) + } + #[inline(always)] + pub fn set(&self, v: T) { + self.0.with(|c| c.set(v)) + } + } + + thread_local! { + static TID_KEY: Cell = const { Cell::new(0) }; + static RNG_KEY: Cell = const { Cell::new(0) }; + } + #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] + thread_local! { + static HEAP_KEY: Cell<*mut Heap> = + const { Cell::new((&raw const EMPTY_HEAP.0).cast_mut()) }; + } + #[cfg_attr( + all(target_vendor = "apple", target_arch = "aarch64", not(miri)), + allow(dead_code) + )] + pub static TID: Slot = Slot(&TID_KEY); + #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] + pub static DEFAULT_HEAP: Slot<*mut Heap> = Slot(&HEAP_KEY); + #[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] + pub use super::tsd::DEFAULT_HEAP; + pub static RNG: Slot = Slot(&RNG_KEY); +} + +#[cfg(feature = "nightly")] +mod tls { + use super::*; + + #[thread_local] + static TID_RAW: Cell = Cell::new(0); + #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] + #[thread_local] + static HEAP_RAW: Cell<*mut Heap> = Cell::new((&raw const EMPTY_HEAP.0).cast_mut()); + #[thread_local] + static RNG_RAW: Cell = Cell::new(0); + + macro_rules! slot { + ($name:ident, $ty:ty, $raw:ident, $slot:ident) => { + pub struct $name; + impl $name { + #[inline(always)] + pub fn get(&self) -> $ty { + $raw.get() + } + #[inline(always)] + pub fn set(&self, v: $ty) { + $raw.set(v) + } + } + pub static $slot: $name = $name; + }; + } + #[cfg_attr( + all(target_vendor = "apple", target_arch = "aarch64", not(miri)), + allow(dead_code) + )] + mod tid { + use super::*; + slot!(TidSlot, usize, TID_RAW, TID); + } + #[cfg_attr( + all(target_vendor = "apple", target_arch = "aarch64", not(miri)), + allow(unused_imports) + )] + pub use tid::TID; + #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] + slot!(HeapSlot, *mut Heap, HEAP_RAW, DEFAULT_HEAP); + #[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] + pub use super::tsd::DEFAULT_HEAP; + slot!(RngSlot, u64, RNG_RAW, RNG); +} + +#[inline(always)] +pub fn heap_default_unchecked() -> &'static Heap { + // SAFETY: always the static empty heap or this thread's live heap. + unsafe { &*tls::DEFAULT_HEAP.get() } +} + +#[inline(always)] +pub fn thread_is_initialized() -> bool { + tls::DEFAULT_HEAP.get() != empty_heap() +} + +/// `mi_heap_get_default`: initializes the thread if needed. +#[inline] +pub fn heap_default() -> &'static Heap { + if !thread_is_initialized() { + thread_init(); + } + heap_default_unchecked() +} + +pub fn heap_set_default(heap: &Heap) -> &'static Heap { + let old = heap_default(); + tls::DEFAULT_HEAP.set(ptr::from_ref(heap).cast_mut()); + old +} + +// --------------------------------------------------------------------------- +// Thread metadata allocation (OS-backed so it does not recurse into malloc) +// --------------------------------------------------------------------------- + +#[repr(C)] +struct ThreadData { + heap: Heap, // must be first (cast in thread_done) + tld: Tld, + mem: OsMem, +} + +static THREAD_COUNT: AtomicUsize = AtomicUsize::new(0); + +pub fn current_thread_count() -> usize { + THREAD_COUNT.load(Ordering::Relaxed).max(1) +} + +// Simple wyrand-style RNG for cookies/keys (free lists are not encoded). +pub fn random_next() -> usize { + let mut s = tls::RNG.get(); + if s == 0 { + s = thread_id() as u64 ^ 0x9e37_79b9_7f4a_7c15 ^ (options_entropy() << 1) | 1; + } + s = s.wrapping_add(0xa076_1d64_78bd_642f); + tls::RNG.set(s); + let t = (s as u128).wrapping_mul((s ^ 0xe703_7ed1_a0b4_28db) as u128); + ((t >> 64) ^ t) as usize +} + +#[cfg(not(windows))] +fn options_entropy() -> u64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: valid out-pointer. + unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; + ts.tv_nsec as u64 +} + +#[cfg(windows)] +fn options_entropy() -> u64 { + let mut counter: i64 = 0; + // SAFETY: valid out-pointer. + unsafe { QueryPerformanceCounter(&mut counter) }; + counter as u64 +} + +// Cache of dead ThreadData regions (`mi_thread_data_cache`): thread churn +// must not mmap/munmap per thread. Nodes are written into the dead regions. +struct TdNode { + next: *mut TdNode, + mem: OsMem, +} +struct TdCache { + lock: core::sync::atomic::AtomicBool, + first: Cell<*mut TdNode>, + count: AtomicUsize, +} +// SAFETY: `first` only accessed under `lock`. +unsafe impl Sync for TdCache {} +static TD_CACHE: TdCache = TdCache { + lock: core::sync::atomic::AtomicBool::new(false), + first: Cell::new(ptr::null_mut()), + count: AtomicUsize::new(0), +}; +const TD_CACHE_MAX: usize = 32; + +fn td_locked(f: impl FnOnce() -> R) -> R { + crate::sync::spin_locked(&TD_CACHE.lock, f) +} + +fn td_cache_pop() -> Option<(*mut u8, OsMem)> { + if TD_CACHE.count.load(Ordering::Relaxed) == 0 { + return None; + } + let mem = td_locked(|| { + // SAFETY: nodes are owned by the cache while linked (lock held). + unsafe { + let node = TD_CACHE.first.get().as_ref()?; + TD_CACHE.first.set(node.next); + Some(node.mem) + } + })?; + TD_CACHE.count.fetch_sub(1, Ordering::Relaxed); + Some((mem.base, mem)) +} + +fn td_cache_push(mem: OsMem) -> bool { + if TD_CACHE.count.load(Ordering::Relaxed) >= TD_CACHE_MAX { + return false; + } + // SAFETY: dead, fully-committed region; node lives at the tail, away + // from the Heap header which protected references may still cover. + let node = unsafe { crate::os::tail_node::(&mem) }; + td_locked(|| { + // SAFETY: dead region owned by the cache. + unsafe { + node.write(TdNode { + next: TD_CACHE.first.get(), + mem, + }) + }; + TD_CACHE.first.set(node); + }); + TD_CACHE.count.fetch_add(1, Ordering::Relaxed); + true +} + +// --------------------------------------------------------------------------- +// Thread init / done +// --------------------------------------------------------------------------- + +static THREAD_KEY: AtomicUsize = AtomicUsize::new(usize::MAX); + +#[cfg(not(windows))] +pub(crate) fn td_cache_lock() -> &'static core::sync::atomic::AtomicBool { + &TD_CACHE.lock +} + +// --------------------------------------------------------------------------- +// Unix: pthread TSD thread-exit callback +// --------------------------------------------------------------------------- + +#[cfg(not(windows))] +/// Register the `pthread_atfork` handlers that hold every allocator lock +/// across `fork()`. This MUST run before any thread or fork exists, so it +/// lives in a load-time constructor (`register_fork_handlers`); doing it +/// lazily on first allocation lets a fork race a half-registered or +/// orphaned-lock state into the child. Split out so the lazy fallback (for +/// builds without a usable load-time constructor) can reuse it. +fn register_fork_handlers() { + // SAFETY: a plain libc call with three valid handler fns; the handlers + // only touch allocator spinlocks and never allocate. + unsafe { + libc::pthread_atfork( + Some(crate::os::fork_prepare), + Some(crate::os::fork_release), + Some(crate::os::fork_release), + ); + } +} + +#[cfg(not(windows))] +unsafe extern "C" fn thread_done_callback(value: *mut libc::c_void) { + // On macOS the dynamic loader tears down `#[thread_local]` storage + // before pthread TSD destructors run, so the heap arrives as the key + // value (mimalloc does the same in `prim/unix`). + if !value.is_null() { + // SAFETY: the value is this thread's backing heap, still live. + thread_done_with(unsafe { &*value.cast::() }); + } +} + +// --------------------------------------------------------------------------- +// Windows: FLS thread-exit callback +// --------------------------------------------------------------------------- + +#[cfg(windows)] +unsafe extern "system" { + fn FlsAlloc(callback: Option) -> u32; + fn FlsSetValue(fls_handle: u32, value: *mut core::ffi::c_void) -> i32; + fn GetCurrentThreadId() -> u32; + fn QueryPerformanceCounter(lpPerformanceCount: *mut i64) -> i32; +} + +#[cfg(windows)] +fn register_fork_handlers() { + // No fork on Windows. +} + +#[cfg(windows)] +unsafe extern "system" fn thread_done_callback(value: *mut core::ffi::c_void) { + if !value.is_null() { + // SAFETY: the value is this thread's backing heap, still live. + thread_done_with(unsafe { &*value.cast::() }); + } +} + +// --------------------------------------------------------------------------- + +/// Runs at image load, before `main` and before any thread or fork exists, +/// so the atfork handlers are always registered ahead of any `fork()`. The +/// body is a single libc call and never allocates, so it is safe even when +/// `rimalloc` is the `#[global_allocator]` (the allocator is not used yet). +/// +/// Excluded under `fuzzing`: cargo-fuzz's sancov/ASan linker rejects the +/// constructor's `__init_offsets` entry (`initializer pointer has no +/// target`); the fuzz harness is single-process and never forks, so the +/// lazy fallback below suffices there. +#[cfg(all(not(miri), not(fuzzing)))] +#[ctor::ctor(unsafe)] +fn fork_handler_ctor() { + register_fork_handlers(); +} + +fn ensure_process_init() { + static ONCE: AtomicUsize = AtomicUsize::new(0); + if ONCE.swap(1, Ordering::AcqRel) == 0 { + #[cfg(not(windows))] + // SAFETY: valid out-pointer and destructor fn. + unsafe { + let mut key: libc::pthread_key_t = 0; + libc::pthread_key_create(&mut key, Some(thread_done_callback)); + THREAD_KEY.store(key as usize, Ordering::Release); + } + #[cfg(windows)] + // SAFETY: registers a thread-exit callback via FLS. + unsafe { + let key = FlsAlloc(Some(thread_done_callback)); + // FLS_OUT_OF_INDEXES = 0xFFFFFFFF + if key != u32::MAX { + THREAD_KEY.store(key as usize, Ordering::Release); + } + } + // Builds without the load-time constructor (Miri's interpreter, + // fuzzing's sancov link) register the handlers lazily here. Neither + // forks, so the registration-window race the constructor closes does + // not apply. + #[cfg(any(miri, fuzzing))] + register_fork_handlers(); + } +} + +/// `mi_thread_init`: set up this thread's tld + backing heap. +pub fn thread_init() { + if thread_is_initialized() { + return; + } + ensure_process_init(); + + let size = crate::constants::align_up(size_of::(), crate::os::page_size()); + let Some((p, mem)) = + td_cache_pop().or_else(|| OsMem::alloc_aligned(size, crate::os::page_size(), true)) + else { + return; + }; + let td = p.cast::(); + // SAFETY: fresh OS memory, sized for ThreadData; we initialize all fields. + unsafe { + td.write(ThreadData { + heap: Heap::new_empty(), + tld: Tld::new(), + mem, + }); + let heap = &raw mut (*td).heap; + let tld = &raw mut (*td).tld; + (*tld).heap_backing.set(heap); + crate::heap_ops::heap_init(&*heap, tld, false, 0, 0); + tls::DEFAULT_HEAP.set(heap); + } + THREAD_COUNT.fetch_add(1, Ordering::Relaxed); + crate::stats::STATS.threads.increase(1); + + let key = THREAD_KEY.load(Ordering::Acquire); + if key != usize::MAX { + #[cfg(not(windows))] + // SAFETY: key was created in ensure_process_init; the value is the + // backing heap pointer, handed to the destructor at thread exit. + unsafe { + libc::pthread_setspecific(key as libc::pthread_key_t, tls::DEFAULT_HEAP.get().cast()) + }; + #[cfg(windows)] + // SAFETY: key was created in ensure_process_init via FlsAlloc. + unsafe { + FlsSetValue(key as u32, tls::DEFAULT_HEAP.get().cast()); + } + } +} + +/// `_mi_thread_done` +pub fn thread_done() { + if !thread_is_initialized() { + return; + } + thread_done_with(heap_default_unchecked()); +} + +/// `_mi_thread_heap_done`: tear down a thread given its (backing) heap. +/// `heap` must belong to the current thread. +fn thread_done_with(heap: &Heap) { + if !heap.is_initialized() { + return; // already done + } + tls::DEFAULT_HEAP.set(empty_heap()); + // Clear the FLS/TSD value so the destructor does not fire again on a heap + // we are about to free. + let key = THREAD_KEY.load(Ordering::Acquire); + if key != usize::MAX { + #[cfg(not(windows))] + // SAFETY: valid key. + unsafe { libc::pthread_setspecific(key as libc::pthread_key_t, ptr::null()) }; + #[cfg(windows)] + // SAFETY: valid FLS key. + unsafe { FlsSetValue(key as u32, ptr::null_mut()) }; + } + + // SAFETY: this thread's tld/heaps are live until we free them below. + unsafe { + let tld = &*heap.tld.get(); + // The raw backing pointer carries provenance over the whole + // ThreadData (set in thread_init); keep it for the final free. + let backing_ptr = tld.heap_backing.get(); + let backing = &*backing_ptr; + + // delete all non-backing heaps + let mut curr = tld.heaps.get(); + while let Some(h) = curr.as_ref() { + let next = h.next.get(); + if !ptr::eq(h, backing) { + crate::heap_ops::heap_delete(h); + } + curr = next; + } + + // abandon what is still live + crate::heap_ops::heap_collect_abandon(backing); + + // free the thread metadata (cache it for the next thread) + let td = backing_ptr.cast::(); + let mem = (*td).mem; + if !td_cache_push(mem) { + crate::os::defer_free(mem); + } + } + THREAD_COUNT.fetch_sub(1, Ordering::Relaxed); + crate::stats::STATS.threads.decrease(1); + crate::os::drain_deferred(); +} diff --git a/vendor/rimalloc/src/lib.rs b/vendor/rimalloc/src/lib.rs new file mode 100644 index 00000000..1c5f57bf --- /dev/null +++ b/vendor/rimalloc/src/lib.rs @@ -0,0 +1,95 @@ +#![cfg_attr(feature = "nightly", feature(thread_local))] +//! # rimalloc +//! +//! A pure-Rust port of [mimalloc](https://github.com/microsoft/mimalloc) +//! (v2.3.2): a compact general-purpose allocator with per-thread heaps, +//! sharded free lists, and lock-free cross-thread frees. +//! +//! ``` +//! use rimalloc::Rimalloc; +//! +//! // #[global_allocator] +//! // static ALLOC: Rimalloc = Rimalloc; +//! let p = rimalloc::malloc(42); +//! assert!(rimalloc::usable_size(p) >= 42); +//! rimalloc::free(p); +//! ``` + +#![allow(clippy::missing_safety_doc)] + +mod alloc; +mod api; +mod arena; +mod bins; +pub mod bitmap; +mod constants; +mod free; +mod heap; +mod heap_ops; +pub mod hooks; +mod init; +pub mod options; +mod os; +mod page; +mod page_ops; +pub mod protocol; +mod segment; +mod stats; +mod sync; +mod visit; + +pub use api::*; +pub use bins::good_size as mi_good_size; +pub use heap::Heap; +pub use visit::{HeapArea, heap_visit_blocks}; + +// Errno constants: POSIX values are universal. +#[cfg(not(windows))] +pub use libc::{EFAULT, EINVAL, ENOMEM}; +#[cfg(windows)] +pub const EFAULT: i32 = 14; +#[cfg(windows)] +pub const EINVAL: i32 = 22; +#[cfg(windows)] +pub const ENOMEM: i32 = 12; + +/// `_mi_error_message`: route to the registered output/error hooks +/// (stderr by default) without allocating. +pub(crate) fn error(msg: &str) { + error_code(msg, EFAULT) +} + +pub(crate) fn error_code(msg: &str, code: i32) { + hooks::output("rimalloc: error: "); + hooks::output(msg); + hooks::output("\n"); + hooks::error(code); +} + +/// Bridge for the Lean conformance tests (`tests/lean_conformance.rs`): +/// exposes the exact functions the verified model mirrors, so the live +/// implementation can be pinned to the model over its whole domain. +pub mod lean_model { + /// `Bin::from_size` on a word count (what `Bins.lean::binFromWsize` models). + pub fn bin_from_wsize(wsize: usize) -> usize { + crate::bins::Bin::from_size(wsize * crate::constants::PTR_SIZE).index() + } + + /// `BIN_WSIZE` table entry (what `Bins.lean::binWsize` models). + pub fn bin_wsize(bin: usize) -> usize { + crate::bins::BIN_WSIZE[bin] + } + + /// `CommitMask::create` as raw words (what `CommitMask.lean::createLoop` + /// models). + pub fn commit_mask_create_words(idx: usize, count: usize) -> [usize; 8] { + let cm = crate::segment::CommitMask::create(idx, count); + cm.to_words() + } + + /// Page block-area start offset (what `PageStart.lean::startOffset` + /// models). + pub fn page_start_offset(block_size: usize, pstart_addr: usize, psize: usize) -> usize { + crate::segment::page_start_offset(block_size, pstart_addr, psize) + } +} diff --git a/vendor/rimalloc/src/options.rs b/vendor/rimalloc/src/options.rs new file mode 100644 index 00000000..88789ff9 --- /dev/null +++ b/vendor/rimalloc/src/options.rs @@ -0,0 +1,246 @@ +//! Runtime options (port of `options.c`): each option reads its +//! `MIMALLOC_*` environment variable once and is settable at runtime +//! (`mi_option_get`/`mi_option_set`). + +use core::sync::atomic::{AtomicI64, Ordering}; + +macro_rules! options { + ($($variant:ident => $name:ident: $default:expr, $env:literal;)*) => { + /// `mi_option_t` + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + #[repr(usize)] + pub enum Option { + $($variant),* + } + + const COUNT: usize = [$($env),*].len(); + const DEFAULTS: [(i64, &str); COUNT] = [$(($default, $env)),*]; + // i64::MIN marks "not yet initialized from the environment". + static VALUES: [AtomicI64; COUNT] = [const { AtomicI64::new(i64::MIN) }; COUNT]; + + $( + /// Internal shorthand for this option's value. + #[allow(dead_code)] + #[inline] + pub fn $name() -> i64 { + get(Option::$variant) + } + )* + }; +} + +options! { + EagerCommit => eager_commit: 1, "MIMALLOC_EAGER_COMMIT"; + EagerCommitDelay => eager_commit_delay: 0, "MIMALLOC_EAGER_COMMIT_DELAY"; + PurgeDelay => purge_delay: 10, "MIMALLOC_PURGE_DELAY"; + PurgeExtendDelay => purge_extend_delay: 1, "MIMALLOC_PURGE_EXTEND_DELAY"; + AbandonedPagePurge => abandoned_page_purge: 0, "MIMALLOC_ABANDONED_PAGE_PURGE"; + FullPageRetain => full_page_retain: 2, "MIMALLOC_FULL_PAGE_RETAIN"; + MaxPageCandidates => max_page_candidates: 4, "MIMALLOC_MAX_PAGE_CANDIDATES"; + MaxSegmentReclaim => max_segment_reclaim: 10, "MIMALLOC_MAX_SEGMENT_RECLAIM"; + AbandonedReclaimOnFree => abandoned_reclaim_on_free: 0, "MIMALLOC_ABANDONED_RECLAIM_ON_FREE"; + TargetSegmentsPerThread => target_segments_per_thread: 0, "MIMALLOC_TARGET_SEGMENTS_PER_THREAD"; + PurgeDecommits => purge_decommits: 1, "MIMALLOC_PURGE_DECOMMITS"; + ArenaReserve => arena_reserve: 1024 * 1024, "MIMALLOC_ARENA_RESERVE"; // KiB + ShowErrors => show_errors: 0, "MIMALLOC_SHOW_ERRORS"; + ShowStats => show_stats: 0, "MIMALLOC_SHOW_STATS"; + Verbose => verbose: 0, "MIMALLOC_VERBOSE"; +} + +/// Case-insensitive comparison of `s` against a short literal, without +/// allocating (avoids `to_ascii_lowercase` → String). +fn eq_ignore_case(s: &str, lit: &str) -> bool { + s.len() == lit.len() && s.as_bytes().iter().zip(lit.as_bytes()) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) +} + +fn parse(s: &str) -> std::option::Option { + if s.is_empty() || eq_ignore_case(s, "on") || eq_ignore_case(s, "yes") || eq_ignore_case(s, "true") + { + Some(1) + } else if eq_ignore_case(s, "off") || eq_ignore_case(s, "no") || eq_ignore_case(s, "false") { + Some(0) + } else { + s.parse().ok() + } +} + +/// Read an environment variable using raw OS calls that do **not** go +/// through `#[global_allocator]`. `std::env::var()` allocates a `String`, +/// which would recurse into `Rimalloc::alloc()` when called from the +/// allocation slow path. +fn read_env(env: &str) -> std::option::Option { + let mut buf = [0u8; 512]; + + #[cfg(unix)] + let val = { + let mut cstr = [0i8; 128]; + let b = env.as_bytes(); + if b.len() + 1 > cstr.len() { + return None; + } + let mut i = 0; + while i < b.len() { + cstr[i] = b[i] as i8; + i += 1; + } + cstr[i] = 0; + let p = unsafe { libc::getenv(cstr.as_ptr()) }; + if p.is_null() { + return None; + } + let s = unsafe { core::ffi::CStr::from_ptr(p) }; + s.to_str().ok()? + }; + + #[cfg(windows)] + let val = { + // env → UTF‑16 name + let mut name_wide: [u16; 128] = [0; 128]; + for (name_len, c) in env.encode_utf16().enumerate() { + if name_len >= name_wide.len() - 1 { + return None; + } + name_wide[name_len] = c; + } + + // Get value length + let required = + unsafe { GetEnvironmentVariableW(name_wide.as_ptr(), core::ptr::null_mut(), 0) }; + if required == 0 { + return None; + } + + // Read value wide + let mut val_wide: [u16; 256] = [0; 256]; + if (required as usize) > val_wide.len() { + return None; + } + let n = unsafe { + GetEnvironmentVariableW( + name_wide.as_ptr(), + val_wide.as_mut_ptr(), + val_wide.len() as u32, + ) + }; + if n == 0 { + return None; + } + + // Wide → UTF‑8 via stack buffer + let utf8_len = unsafe { + WideCharToMultiByte( + 65001, 0, val_wide.as_ptr(), n as i32, + core::ptr::null_mut(), 0, + core::ptr::null(), core::ptr::null_mut(), + ) + }; + if utf8_len <= 0 || (utf8_len as usize) >= buf.len() { + return None; + } + let written = unsafe { + WideCharToMultiByte( + 65001, 0, val_wide.as_ptr(), n as i32, + buf.as_mut_ptr(), buf.len() as i32, + core::ptr::null(), core::ptr::null_mut(), + ) + }; + if written <= 0 { + return None; + } + core::str::from_utf8(&buf[..written as usize]).ok()? + }; + + parse(val) +} + +/// `mi_option_get` +pub fn get(option: Option) -> i64 { + let idx = option as usize; + match VALUES[idx].load(Ordering::Relaxed) { + i64::MIN => { + let (default, env) = DEFAULTS[idx]; + let v = read_env(env).unwrap_or(default); + VALUES[idx].store(v, Ordering::Relaxed); + v + } + v => v, + } +} + +/// `mi_option_set` +pub fn set(option: Option, value: i64) { + VALUES[option as usize].store(value, Ordering::Relaxed); +} + +/// `mi_option_is_enabled` +pub fn is_enabled(option: Option) -> bool { + get(option) != 0 +} + +/// `mi_option_enable` / `mi_option_disable` +pub fn enable(option: Option) { + set(option, 1) +} +pub fn disable(option: Option) { + set(option, 0) +} + +/// `_mi_clock_now`: monotonic milliseconds. Only used for relative purge +/// deadlines (>= 10ms granularity), so on Apple the cheap commpage-cached +/// approximate clock is used; `CLOCK_MONOTONIC` there is a gettimeofday + +/// boottime computation that shows up in thread-churn profiles. +#[cfg(all(target_vendor = "apple", not(miri)))] +pub fn clock_now() -> i64 { + unsafe extern "C" { + fn clock_gettime_nsec_np(clock_id: libc::clockid_t) -> u64; + } + // SAFETY: no preconditions. + (unsafe { clock_gettime_nsec_np(libc::CLOCK_MONOTONIC_RAW_APPROX) } / 1_000_000) as i64 +} + +/// `_mi_clock_now`: monotonic milliseconds. +#[cfg(all(not(all(target_vendor = "apple", not(miri))), not(windows)))] +#[allow(clippy::unnecessary_cast)] // tv_sec/tv_nsec are narrower off-macOS +pub fn clock_now() -> i64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: ts is a valid out-pointer. + unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; + ts.tv_sec as i64 * 1000 + ts.tv_nsec as i64 / 1_000_000 +} + +/// `_mi_clock_now`: monotonic milliseconds (Windows via QueryPerformanceCounter). +#[cfg(all(not(all(target_vendor = "apple", not(miri))), windows))] +pub fn clock_now() -> i64 { + // SAFETY: QPF and QPC are safe to call at any time. + unsafe { + let mut freq: i64 = 0; + let mut count: i64 = 0; + QueryPerformanceFrequency(&mut freq); + QueryPerformanceCounter(&mut count); + if freq == 0 { + return 0; + } + (count / freq * 1000) + ((count % freq) * 1000 / freq) + } +} + +#[cfg(windows)] +unsafe extern "system" { + fn QueryPerformanceCounter(lpPerformanceCount: *mut i64) -> i32; + fn QueryPerformanceFrequency(lpFrequency: *mut i64) -> i32; + fn GetEnvironmentVariableW(lpName: *const u16, lpBuffer: *mut u16, nSize: u32) -> u32; + fn WideCharToMultiByte( + CodePage: u32, + dwFlags: u32, + lpWideCharStr: *const u16, + cchWideChar: i32, + lpMultiByteStr: *mut u8, + cbMultiByte: i32, + lpDefaultChar: *const u8, + lpUsedDefaultChar: *mut i32, + ) -> i32; +} diff --git a/vendor/rimalloc/src/os.rs b/vendor/rimalloc/src/os.rs new file mode 100644 index 00000000..a5b6cdc3 --- /dev/null +++ b/vendor/rimalloc/src/os.rs @@ -0,0 +1,842 @@ +//! OS memory primitives (port of `os.c` and `prim/unix/prim.c`). +//! +//! Segments need `SEGMENT_ALIGN`-aligned virtual memory; we over-allocate +//! and trim, the same fallback mimalloc uses. Memory can be reserved +//! without commit (`PROT_NONE`) and committed/purged lazily. + +use core::ptr; +use core::sync::atomic::{AtomicUsize, Ordering}; + +#[cfg(not(windows))] +#[inline] +pub fn page_size() -> usize { + static PAGE_SIZE: AtomicUsize = AtomicUsize::new(0); + match PAGE_SIZE.load(Ordering::Relaxed) { + 0 => { + // SAFETY: sysconf is async-signal-safe and has no memory preconditions. + let sz = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; + PAGE_SIZE.store(sz, Ordering::Relaxed); + sz + } + sz => sz, + } +} + +#[cfg(windows)] +#[inline] +pub fn page_size() -> usize { + static PAGE_SIZE: AtomicUsize = AtomicUsize::new(0); + match PAGE_SIZE.load(Ordering::Relaxed) { + 0 => { + // SAFETY: GetSystemInfo writes into a caller-provided struct; no preconditions. + let mut info = unsafe { core::mem::zeroed::() }; + unsafe { GetSystemInfo(&mut info) }; + let sz = info.dwPageSize as usize; + PAGE_SIZE.store(sz, Ordering::Relaxed); + sz + } + sz => sz, + } +} + +/// An owned range of OS-allocated memory (provenance for a segment). +/// Mirrors the `MI_MEM_OS` case of `mi_memid_t`. +#[derive(Clone, Copy, Debug)] +pub struct OsMem { + pub base: *mut u8, + pub size: usize, + pub align: usize, + pub is_committed: bool, + pub is_zero: bool, +} + +// SAFETY: OsMem is a plain descriptor; ownership transfer is what Send is for. +unsafe impl Send for OsMem {} + +// Under Miri there is no virtual-memory model: partial munmap, PROT_NONE +// reservations, and madvise have no equivalent. Back segments with +// `std::alloc` instead; commit/decommit/reset become no-ops. +#[cfg(miri)] +mod shim { + use super::OsMem; + use core::alloc::Layout; + + impl OsMem { + pub fn alloc_aligned( + size: usize, + alignment: usize, + commit: bool, + ) -> Option<(*mut u8, OsMem)> { + let layout = Layout::from_size_align(size, alignment).ok()?; + // SAFETY: non-zero size and valid layout. + let p = unsafe { std::alloc::alloc_zeroed(layout) }; + if p.is_null() { + return None; + } + p.expose_provenance(); // segment lookups reconstruct pointers by address + Some(( + p, + OsMem { + base: p, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )) + } + + pub fn free(self) { + // SAFETY: base/size/align are exactly what alloc_aligned used. + unsafe { + std::alloc::dealloc( + self.base, + Layout::from_size_align_unchecked(self.size, self.align), + ) + }; + } + } + + pub unsafe fn commit(_p: *mut u8, _size: usize) -> bool { + false + } + pub unsafe fn decommit(_p: *mut u8, _size: usize) {} + pub unsafe fn reset(_p: *mut u8, _size: usize) {} +} + +#[cfg(miri)] +pub use shim::{commit, decommit, reset}; + +/// Syscall counters: [mmap, munmap, madvise, mprotect]. +pub static SYSCALL_COUNTS: [AtomicUsize; 4] = [const { AtomicUsize::new(0) }; 4]; + +pub fn syscall_counts() -> [usize; 4] { + SYSCALL_COUNTS.each_ref().map(|c| c.load(Ordering::Relaxed)) +} + +// --------------------------------------------------------------------------- +// Unix memory allocation (mmap / munmap / mprotect) +// --------------------------------------------------------------------------- + +#[cfg(not(any(miri, windows)))] +fn mmap(hint: *mut u8, size: usize, commit: bool) -> *mut u8 { + SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); + let prot = if commit { + libc::PROT_READ | libc::PROT_WRITE + } else { + libc::PROT_NONE + }; + let flags = libc::MAP_PRIVATE | libc::MAP_ANON; + // SAFETY: anonymous mapping; a hint address never forces placement. + let p = unsafe { libc::mmap(hint.cast(), size, prot, flags, -1, 0) }; + if p == libc::MAP_FAILED { + ptr::null_mut() + } else { + p.cast() + } +} + +#[cfg(not(any(miri, windows)))] +impl OsMem { + /// Allocate `size` bytes aligned to `alignment` (a power of two). + /// Returns the aligned pointer and the owning region. + pub fn alloc_aligned(size: usize, alignment: usize, commit: bool) -> Option<(*mut u8, OsMem)> { + debug_assert!(crate::constants::is_power_of_two(alignment)); + let size = crate::constants::align_up(size, page_size()); + let p = mmap(ptr::null_mut(), size, commit); + if p.is_null() { + return None; + } + if commit { + crate::stats::STATS.committed.increase(size); + } + if p.addr() & (alignment - 1) == 0 { + p.expose_provenance(); // segment lookups reconstruct pointers by address + return Some(( + p, + OsMem { + base: p, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )); + } + // Misaligned: over-allocate and trim both ends. + // SAFETY: p..p+size is our mapping. + unsafe { libc::munmap(p.cast(), size) }; + let over = size + alignment; + let p = mmap(ptr::null_mut(), over, commit); + if p.is_null() { + return None; + } + let aligned = ptr::with_exposed_provenance_mut::( + (p.addr() + alignment - 1) & !(alignment - 1), + ); + let pre = aligned.addr() - p.addr(); + let post = over - pre - size; + // SAFETY: trimming unused head/tail of our own fresh mapping. + unsafe { + if pre > 0 { + libc::munmap(p.cast(), pre); + } + if post > 0 { + libc::munmap(aligned.add(size).cast(), post); + } + } + aligned.expose_provenance(); // segment lookups reconstruct pointers by address + Some(( + aligned, + OsMem { + base: aligned, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )) + } + + /// Unmap the whole region. + pub fn free(self) { + SYSCALL_COUNTS[1].fetch_add(1, Ordering::Relaxed); + // SAFETY: base..base+size is an owned mapping; no references outlive it. + unsafe { libc::munmap(self.base.cast(), self.size) }; + } +} + +/// Commit a range: make it readable/writable. Returns `is_zero`. +/// +/// # Safety +/// `p..p+size` must lie within a single [`OsMem`] region. +#[cfg(not(any(miri, windows)))] +pub unsafe fn commit(p: *mut u8, size: usize) -> bool { + let (p, size) = page_span(p, size, true); + SYSCALL_COUNTS[3].fetch_add(1, Ordering::Relaxed); + // SAFETY: caller guarantees the range is within an owned mapping. + unsafe { libc::mprotect(p.cast(), size, libc::PROT_READ | libc::PROT_WRITE) }; + false // conservatively not known-zero (PROT_NONE ranges were never written though) +} + +/// Decommit a range: drop its pages and make it inaccessible. +/// +/// # Safety +/// `p..p+size` must lie within a single [`OsMem`] region and have no live +/// references. +#[cfg(not(any(miri, windows)))] +pub unsafe fn decommit(p: *mut u8, size: usize) { + let (p, size) = page_span(p, size, false); + if size == 0 { + return; + } + SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); + // SAFETY: per caller contract; MAP_FIXED re-map keeps the reservation. + unsafe { + libc::mmap( + p.cast(), + size, + libc::PROT_NONE, + libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_FIXED, + -1, + 0, + ); + } +} + +// --------------------------------------------------------------------------- +// Windows memory allocation (VirtualAlloc / VirtualFree / VirtualProtect) +// --------------------------------------------------------------------------- + +#[cfg(windows)] +unsafe extern "system" { + fn VirtualAlloc( + lpAddress: *mut core::ffi::c_void, + dwSize: usize, + flAllocationType: u32, + flProtect: u32, + ) -> *mut core::ffi::c_void; + fn VirtualFree( + lpAddress: *mut core::ffi::c_void, + dwSize: usize, + dwFreeType: u32, + ) -> i32; + fn VirtualProtect( + lpAddress: *mut core::ffi::c_void, + dwSize: usize, + flNewProtect: u32, + lpflOldProtect: *mut u32, + ) -> i32; + fn GetSystemInfo(lpSystemInfo: *mut SYSTEM_INFO); +} + +#[cfg(windows)] +#[repr(C)] +#[allow(non_snake_case)] +struct SYSTEM_INFO { + _union: [u8; 4], // wProcessorArchitecture union + dwPageSize: u32, + lpMinimumApplicationAddress: *mut core::ffi::c_void, + lpMaximumApplicationAddress: *mut core::ffi::c_void, + dwActiveProcessorMask: usize, + dwNumberOfProcessors: u32, + dwProcessorType: u32, + dwAllocationGranularity: u32, + wProcessorLevel: u16, + wProcessorRevision: u16, +} + +#[cfg(windows)] +const MEM_COMMIT: u32 = 0x1000; +#[cfg(windows)] +const MEM_RESERVE: u32 = 0x2000; +#[cfg(windows)] +const MEM_RELEASE: u32 = 0x8000; +#[cfg(windows)] +const MEM_DECOMMIT: u32 = 0x4000; +#[cfg(windows)] +const MEM_RESET: u32 = 0x80000; +#[cfg(windows)] +const PAGE_NOACCESS: u32 = 0x01; +#[cfg(windows)] +const PAGE_READWRITE: u32 = 0x04; + +#[cfg(windows)] +fn mmap_win(size: usize, commit: bool) -> *mut u8 { + SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); + let flags = if commit { MEM_COMMIT | MEM_RESERVE } else { MEM_RESERVE }; + let prot = if commit { PAGE_READWRITE } else { PAGE_NOACCESS }; + // SAFETY: plain anonymous allocation. + let p = unsafe { VirtualAlloc(ptr::null_mut(), size, flags, prot) }; + p.cast() +} + +#[cfg(windows)] +impl OsMem { + pub fn alloc_aligned(size: usize, alignment: usize, commit: bool) -> Option<(*mut u8, OsMem)> { + debug_assert!(crate::constants::is_power_of_two(alignment)); + let size = crate::constants::align_up(size, page_size()); + let p = mmap_win(size, commit); + if p.is_null() { + return None; + } + if commit { + crate::stats::STATS.committed.increase(size); + } + if p.addr() & (alignment - 1) == 0 { + p.expose_provenance(); + return Some(( + p, + OsMem { + base: p, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )); + } + // Misaligned: over-allocate, release the over-allocation, then + // re-allocate exactly at the aligned address. This releases the + // prefix/suffix virtual address range entirely (no waste), unlike + // decommit which keeps them reserved. + unsafe { VirtualFree(p.cast(), 0, MEM_RELEASE) }; + let over = size + alignment; + let p = mmap_win(over, commit); + if p.is_null() { + return None; + } + let aligned = ptr::with_exposed_provenance_mut::( + (p.addr() + alignment - 1) & !(alignment - 1), + ); + + unsafe { VirtualFree(p.cast(), 0, MEM_RELEASE) }; + let flags = if commit { MEM_COMMIT | MEM_RESERVE } else { MEM_RESERVE }; + let prot = if commit { PAGE_READWRITE } else { PAGE_NOACCESS }; + if !unsafe { VirtualAlloc(aligned.cast(), size, flags, prot).is_null() } { + aligned.expose_provenance(); + return Some(( + aligned, + OsMem { + base: aligned, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )); + } + + // Race: another thread took the address. Fall back to the + // decommit approach (wastes virtual address space but works). + let p = mmap_win(over, commit); + if p.is_null() { + return None; + } + let aligned = ptr::with_exposed_provenance_mut::( + (p.addr() + alignment - 1) & !(alignment - 1), + ); + let pre = aligned.addr() - p.addr(); + let post = over - pre - size; + unsafe { + if pre > 0 { + VirtualFree(p.cast(), pre, MEM_DECOMMIT); + } + if post > 0 { + VirtualFree(aligned.add(size).cast(), post, MEM_DECOMMIT); + } + } + aligned.expose_provenance(); + Some(( + aligned, + OsMem { + base: p, + size, + align: alignment, + is_committed: commit, + is_zero: true, + }, + )) + } + + pub fn free(self) { + SYSCALL_COUNTS[1].fetch_add(1, Ordering::Relaxed); + // SAFETY: self.base is the original VirtualAlloc base. + unsafe { VirtualFree(self.base.cast(), 0, MEM_RELEASE) }; + } +} + +#[cfg(windows)] +pub unsafe fn commit(p: *mut u8, size: usize) -> bool { + let (p, size) = page_span(p, size, true); + if size == 0 { + return false; + } + SYSCALL_COUNTS[3].fetch_add(1, Ordering::Relaxed); + // SAFETY: caller guarantees the range is within an owned mapping. + let ret = unsafe { VirtualAlloc(p.cast(), size, MEM_COMMIT, PAGE_READWRITE) }; + !ret.is_null() +} + +#[cfg(windows)] +pub unsafe fn decommit(p: *mut u8, size: usize) { + let (p, size) = page_span(p, size, false); + if size == 0 { + return; + } + SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); + // SAFETY: per caller contract. + unsafe { VirtualFree(p.cast(), size, MEM_DECOMMIT) }; +} + +// --------------------------------------------------------------------------- +// Cross-platform helpers +// --------------------------------------------------------------------------- + +/// `_mi_os_good_alloc_size`: round a large allocation up to a tier-aligned +/// size, so `usable_size` of a large/huge block equals `good_size` (C parity). +pub fn good_alloc_size(size: usize) -> usize { + let align = if size < 512 * 1024 { + page_size() + } else if size < 2 * 1024 * 1024 { + 64 * 1024 + } else if size < 8 * 1024 * 1024 { + 256 * 1024 + } else if size < 32 * 1024 * 1024 { + 1024 * 1024 + } else { + 4 * 1024 * 1024 + }; + if size >= usize::MAX - align { + size + } else { + crate::constants::align_up(size, align) + } +} + +/// Purge: release physical pages but keep the range accessible. +/// +/// # Safety +/// `p..p+size` must lie within a single committed [`OsMem`] region whose +/// contents may be discarded. +#[cfg(not(any(miri, windows)))] +pub unsafe fn reset(p: *mut u8, size: usize) { + let (p, size) = page_span(p, size, false); + if size == 0 { + return; + } + #[cfg(target_os = "macos")] + const ADVICE: libc::c_int = libc::MADV_FREE; + #[cfg(not(target_os = "macos"))] + const ADVICE: libc::c_int = libc::MADV_DONTNEED; + SYSCALL_COUNTS[2].fetch_add(1, Ordering::Relaxed); + // SAFETY: per caller contract. + unsafe { libc::madvise(p.cast(), size, ADVICE) }; +} + +#[cfg(windows)] +pub unsafe fn reset(p: *mut u8, size: usize) { + let (p, size) = page_span(p, size, false); + if size == 0 { + return; + } + SYSCALL_COUNTS[2].fetch_add(1, Ordering::Relaxed); + // MEM_RESET tells the OS the pages are no longer needed + // (physical pages are released, contents discarded). + // SAFETY: per caller contract. + unsafe { VirtualAlloc(p.cast(), size, MEM_RESET, PAGE_READWRITE) }; +} + +/// Round `(p, size)` to OS page boundaries: conservatively outward when +/// `expand`, inward otherwise. +#[cfg(not(miri))] +fn page_span(p: *mut u8, size: usize, expand: bool) -> (*mut u8, usize) { + let ps = page_size(); + let (start, end) = if expand { + (p.addr() & !(ps - 1), (p.addr() + size + ps - 1) & !(ps - 1)) + } else { + ( + (p.addr() + ps - 1) & !(ps - 1), + (p.addr() + size) & !(ps - 1), + ) + }; + (p.with_addr(start), end.saturating_sub(start)) +} + +// --------------------------------------------------------------------------- +// Deferred frees +// --------------------------------------------------------------------------- +// Freeing a segment while `&Segment`/`&Page` arguments are still on the call +// stack would deallocate memory that live (protected) references point into. +// Instead the region is pushed on a pending list — the node is written into +// the dead region itself — and unmapped later from a shallow call frame. + +use core::sync::atomic::AtomicPtr; + +/// Intrusive node placement for dead regions: at the *tail*, away from the +/// segment/heap headers which may still be covered by live (protected) +/// references up the call stack when the region is queued. +/// +/// # Safety +/// The region must be dead and its tail page committed. +pub(crate) unsafe fn tail_node(mem: &OsMem) -> *mut T { + debug_assert!(size_of::() <= 64 && mem.size >= 128); + // SAFETY: in bounds, 16-aligned (size is page aligned). + unsafe { mem.base.add(mem.size - 64).cast() } +} + +struct PendingFree { + next: *mut PendingFree, + mem: OsMem, +} + +static PENDING: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + +/// Queue an [`OsMem`] region for deallocation at the next [`drain_deferred`]. +pub fn defer_free(mem: OsMem) { + // SAFETY: dead region; ensure the tail page is committed (lazily + // committed segments may have an uncommitted tail, and decommit-mode + // purges may have dropped it). Stat-balanced: the region's committed + // bytes were already counted out by the caller. + let node = unsafe { + let node = tail_node::(&mem); + commit(node.cast(), 64); + node + }; + let mut head = PENDING.load(Ordering::Relaxed); + loop { + // SAFETY: the region is dead; we own it until the actual unmap. + unsafe { node.write(PendingFree { next: head, mem }) }; + match PENDING.compare_exchange_weak(head, node, Ordering::Release, Ordering::Relaxed) { + Ok(_) => return, + Err(h) => head = h, + } + } +} + +/// Release all pending regions. Call only from frames that hold no +/// references into allocator-owned memory regions. +pub fn drain_deferred() { + cache_purge_expired(); + if PENDING.load(Ordering::Relaxed).is_null() { + return; + } + let mut node = PENDING.swap(ptr::null_mut(), Ordering::Acquire); + while !node.is_null() { + // SAFETY: nodes are exclusively ours after the swap. + let PendingFree { next, mem } = unsafe { node.read() }; + mem.free(); + node = next; + } +} + +// --------------------------------------------------------------------------- +// Segment cache +// --------------------------------------------------------------------------- +// Returning each 32MiB segment to the OS makes segment churn syscall-bound +// (mimalloc avoids this with arenas). Cache standard segments on a global +// free stack: physical pages are released with MADV_FREE on push, but the +// mapping stays alive so reuse needs no syscall at all. + +const CACHE_MAX: usize = 64; + +struct CachedRegion { + next: *mut CachedRegion, + mem: OsMem, + expire: i64, // purge physical pages if still cached past this time + purged: bool, +} + +struct Cache { + lock: core::sync::atomic::AtomicBool, + first: core::cell::Cell<*mut CachedRegion>, + count: AtomicUsize, +} +// SAFETY: `first` is only accessed under `lock`. +unsafe impl Sync for Cache {} + +static CACHE: Cache = Cache { + lock: core::sync::atomic::AtomicBool::new(false), + first: core::cell::Cell::new(ptr::null_mut()), + count: AtomicUsize::new(0), +}; + +/// Earliest expiry among cached regions (0 = nothing pending). Checked +/// lock-free on the allocation path; only when it passes do we take the +/// cache lock (the previous unconditional lock+walk serialized all threads +/// in `malloc_generic`). +static PURGE_EXPIRE: core::sync::atomic::AtomicI64 = core::sync::atomic::AtomicI64::new(0); + +fn cache_locked(f: impl FnOnce() -> R) -> R { + crate::sync::spin_locked(&CACHE.lock, f) +} + +/// Try to cache a standard segment region; false if the cache is full or the +/// region is non-standard. +pub fn cache_push(mem: OsMem, standard: bool) -> bool { + if !standard || CACHE.count.load(Ordering::Relaxed) >= CACHE_MAX { + return false; + } + // Ensure the whole mapping is committed (cached regions are handed out + // as fully committed): lazily committed segments may have uncommitted + // ranges and decommit-mode purges may have dropped some. + if !mem.is_committed || crate::options::purge_decommits() != 0 { + // SAFETY: we own the dead region. + unsafe { commit(mem.base, mem.size) }; + } + // SAFETY: dead region, fully committed above. + let node = unsafe { tail_node::(&mem) }; + let expire = crate::options::clock_now() + 10 * crate::options::purge_delay().max(1); + // Track the earliest pending expiry (monotonic clock, so a simple + // "set if sooner or unset" race is benign). + let cur = PURGE_EXPIRE.load(Ordering::Relaxed); + if cur == 0 || expire < cur { + PURGE_EXPIRE.store(expire, Ordering::Relaxed); + } + cache_locked(|| { + // SAFETY: dead region, first page committed, owned by the cache. + unsafe { + node.write(CachedRegion { + next: CACHE.first.get(), + mem, + expire, + purged: false, + }); + } + CACHE.first.set(node); + }); + CACHE.count.fetch_add(1, Ordering::Relaxed); + true +} + +/// Pop a cached standard segment region (already committed, NOT zeroed). +pub fn cache_pop() -> Option<(*mut u8, OsMem)> { + if CACHE.count.load(Ordering::Relaxed) == 0 { + return None; + } + let mem = cache_locked(|| { + // SAFETY: list nodes are owned by the cache while linked. + unsafe { + let node = CACHE.first.get().as_ref()?; + CACHE.first.set(node.next); + Some(node.mem) + } + })?; + CACHE.count.fetch_sub(1, Ordering::Relaxed); + let mut mem = mem; + mem.is_zero = false; + mem.is_committed = true; + Some((mem.base, mem)) +} + +/// Purge physical pages of cached regions that sat unused past their +/// expiration (keeps RSS bounded without per-push madvise cost). +fn cache_purge_expired() { + // Lock-free fast path: nothing pending or not yet due. + let expire = PURGE_EXPIRE.load(Ordering::Relaxed); + if expire == 0 || CACHE.count.load(Ordering::Relaxed) == 0 { + return; + } + let now = crate::options::clock_now(); + if now < expire { + return; + } + // Collect candidates under the lock, purge outside it (madvise can be + // slow and must not extend the critical section). At most 8 regions per + // pass; the rest keep their purge bit and go out on a later drain. + let mut to_purge: [(*mut u8, usize); 8] = [(ptr::null_mut(), 0); 8]; + let mut n = 0; + cache_locked(|| { + let tail_span = page_size(); // keep the tail node page intact + let mut next_expire: i64 = 0; + let mut p = CACHE.first.get(); + // SAFETY: nodes are owned by the cache while linked and we hold the lock. + while let Some(node) = unsafe { p.as_mut() } { + if !node.purged && node.expire <= now && node.mem.size > 2 * tail_span && n < 8 { + node.purged = true; + to_purge[n] = (node.mem.base, node.mem.size - tail_span); + n += 1; + } else if !node.purged && (next_expire == 0 || node.expire < next_expire) { + next_expire = node.expire; + } + p = node.next; + } + PURGE_EXPIRE.store(next_expire, Ordering::Relaxed); + }); + for &(p, len) in &to_purge[..n] { + if !p.is_null() { + // SAFETY: purged ranges belong to cached (dead) regions; a + // racing pop simply refaults the pages. + unsafe { reset(p, len) }; + } + } +} + +/// Flush the segment cache back to the OS (used by `mi_collect(force)`). +pub fn cache_collect() { + while let Some((_, mem)) = cache_pop() { + mem.free(); + } +} + +// --------------------------------------------------------------------------- +// Huge OS pages +// --------------------------------------------------------------------------- + +/// Allocate `pages` 1GiB huge OS pages (`_mi_os_alloc_huge_os_pages`). +/// Supported on Linux via `MAP_HUGETLB`; unsupported elsewhere (returns +/// `None`, like mimalloc's graceful fallback). +#[cfg(all(target_os = "linux", not(any(miri, windows))))] +pub fn alloc_huge_os_pages(pages: usize) -> Option<(*mut u8, OsMem)> { + let size = pages << 30; + const MAP_HUGE_1GB: libc::c_int = 30 << 26; // MAP_HUGE_SHIFT + // SAFETY: anonymous huge-page mapping. + let p = unsafe { + libc::mmap( + ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_HUGETLB | MAP_HUGE_1GB, + -1, + 0, + ) + }; + if p == libc::MAP_FAILED { + return None; + } + let p: *mut u8 = p.cast(); + p.expose_provenance(); + Some(( + p, + OsMem { + base: p, + size, + align: 1 << 30, + is_committed: true, + is_zero: true, + }, + )) +} + +#[cfg(not(all(target_os = "linux", not(any(miri, windows)))))] +pub fn alloc_huge_os_pages(_pages: usize) -> Option<(*mut u8, OsMem)> { + None // no huge OS page support on this platform +} + +/// Make a range inaccessible (`_mi_os_protect`); no-op under Miri. +/// +/// # Safety +/// `p..p+size` must lie within an owned, committed mapping with no live +/// references. +pub unsafe fn protect(p: *mut u8, size: usize) { + #[cfg(not(any(miri, windows)))] + // SAFETY: per caller contract. + unsafe { + libc::mprotect(p.cast(), size, libc::PROT_NONE); + } + #[cfg(windows)] + // SAFETY: per caller contract. + unsafe { + let mut old = 0u32; + VirtualProtect(p.cast(), size, PAGE_NOACCESS, &mut old); + } + #[cfg(miri)] + let _ = (p, size); +} + +/// Restore read/write access (`_mi_os_unprotect`); no-op under Miri. +/// +/// # Safety +/// `p..p+size` must lie within an owned mapping. +pub unsafe fn unprotect(p: *mut u8, size: usize) { + #[cfg(not(any(miri, windows)))] + // SAFETY: per caller contract. + unsafe { + libc::mprotect(p.cast(), size, libc::PROT_READ | libc::PROT_WRITE); + } + #[cfg(windows)] + // SAFETY: per caller contract. + unsafe { + let mut old = 0u32; + VirtualProtect(p.cast(), size, PAGE_READWRITE, &mut old); + } + #[cfg(miri)] + let _ = (p, size); +} + +// --------------------------------------------------------------------------- +// fork() handling +// --------------------------------------------------------------------------- +// fork() snapshots memory mid-flight: if another thread holds one of the +// allocator's spinlocks at fork, the child would inherit a locked lock (and +// a possibly half-edited list) forever. `prepare` acquires every global +// lock so the snapshot is consistent; `parent`/`child` release them. Other +// threads do not exist in the child, so their heaps/segments simply remain +// abandoned-or-orphaned there (same policy as mimalloc). + +/// All global spinlocks, in a fixed acquisition order. +#[cfg(not(windows))] +fn fork_locks() -> [&'static AtomicBool; 2] { + [&CACHE.lock, crate::init::td_cache_lock()] +} + +#[cfg(not(windows))] +pub(crate) extern "C" fn fork_prepare() { + crate::segment::abandoned::lock_for_fork(); + for l in fork_locks() { + crate::sync::spin_acquire(l); + } + crate::arena::lock_for_fork(); +} + +#[cfg(not(windows))] +pub(crate) extern "C" fn fork_release() { + crate::arena::unlock_for_fork(); + for l in fork_locks() { + crate::sync::spin_release(l); + } + crate::segment::abandoned::unlock_for_fork(); +} + +#[cfg(not(windows))] +use core::sync::atomic::AtomicBool; diff --git a/vendor/rimalloc/src/page.rs b/vendor/rimalloc/src/page.rs new file mode 100644 index 00000000..caa0c03d --- /dev/null +++ b/vendor/rimalloc/src/page.rs @@ -0,0 +1,410 @@ +//! The mimalloc page (`mi_page_t`): a 64KiB–512KiB region serving blocks of +//! one size class, with three free lists: +//! - `free`: blocks `malloc` pops from (owner thread only), +//! - `local_free`: blocks freed by the owner, migrated to `free` on exhaustion, +//! - `xthread_free`: lock-free stack of blocks freed by other threads. +//! +//! Invariants (see `types.h`): +//! `used - |thread_free|` = live blocks; +//! `used + |free| + |local_free| = capacity <= reserved`. +//! +//! All fields are `Cell`/atomics and accessed through `&Page`, never `&mut`: +//! page metadata lives inside segments and is reachable from other threads +//! (via `xthread_free`), so exclusive references would be unsound. + +use core::cell::Cell; +use core::ptr; +use core::sync::atomic::{AtomicUsize, Ordering}; + +use crate::constants::*; +use crate::heap::Heap; +use crate::segment::Segment; + +/// A free block: the first word is the (unencoded) next pointer. +#[repr(transparent)] +pub struct Block { + pub next: Cell<*mut Block>, +} + +impl Block { + /// View a user pointer as a block pointer (a plain cast; any + /// dereference remains the caller's obligation). + #[inline(always)] + pub fn at(p: *mut u8) -> *mut Block { + p.cast() + } + + /// Set the next pointer of a block that is *entering* a free list. + /// Must not go through `Cell::set` (= `replace`), which reads the old + /// value: the block's memory may be uninitialized or hold partial user + /// data, and reading it as a pointer is UB. + /// + /// # Safety + /// `this` must point to block-sized memory owned by the allocator. + #[inline(always)] + pub unsafe fn write_next(this: *mut Block, next: *mut Block) { + // SAFETY: per contract; plain write, no read of the old value. + unsafe { + this.write(Block { + next: Cell::new(next), + }) + } + } +} + +/// `mi_delayed_t`: the bottom two bits of `xthread_free`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum Delayed { + /// Push on the owning heap's delayed list. + UseDelayedFree = 0, + /// Transient: another thread is accessing the owning heap. + Freeing = 1, + /// Push on the page-local thread free queue. + NoDelayedFree = 2, + /// Sticky: page is abandoned (no owning heap); reset on reclaim. + NeverDelayedFree = 3, +} + +impl Delayed { + #[inline(always)] + pub fn from_bits(bits: usize) -> Delayed { + match bits & 3 { + 0 => Delayed::UseDelayedFree, + 1 => Delayed::Freeing, + 2 => Delayed::NoDelayedFree, + _ => Delayed::NeverDelayedFree, + } + } +} + +/// `mi_thread_free_t`: a block pointer with [`Delayed`] flags in the low +/// bits and the list length in the top 16 (see `protocol::TF_COUNT_SHIFT`). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ThreadFree(pub usize); + +impl ThreadFree { + #[inline(always)] + pub fn block(self) -> *mut Block { + ptr::with_exposed_provenance_mut(self.0 & crate::protocol::TF_PTR_MASK) + } + #[inline(always)] + pub fn count(self) -> usize { + self.0 >> crate::protocol::TF_COUNT_SHIFT + } + #[inline(always)] + pub fn delayed(self) -> Delayed { + Delayed::from_bits(self.0) + } +} + +/// A mimalloc page. When unallocated, the same struct doubles as a slice +/// span header (`mi_slice_t`): `slice_count`/`slice_offset` describe the +/// span and `block_size` is 1 if used, 0 if free. +/// +/// The metadata is split into three cache lines (128 bytes on Apple +/// Silicon) so concurrent cross-thread frees neither false-share with the +/// owner's malloc fast path nor with *each other*: +/// - line 0 is init-time-constant and read-shared (span lookup fields and +/// the unalign constants every foreign free needs) — it stays valid in +/// every core's cache; +/// - line 1 holds only the contended atomics (`xthread_free`, `xheap`) +/// that foreign frees CAS, so that traffic invalidates nothing else; +/// - line 2 has the owner's per-allocation fields (`free`, `local_free`, +/// `used`, queue links). +#[repr(C, align(128))] +pub struct Page { + // ---- line 0: init-constant, read by everyone ---- + // "Owned" by the segment: + pub slice_count: Cell, // slices in this page (0 if not a page) + pub slice_offset: Cell, // byte distance to the first slice of the span + pub is_committed: Cell, + pub is_zero_init: Cell, + pub is_huge: Cell, + pub heap_tag: Cell, + pub block_size_shift: Cell, // nonzero iff block_size == 1<, + pub page_start: Cell<*mut u8>, + /// Lemire fastmod reciprocal of `block_size` (`u64::MAX / b + 1`), or 0 + /// when unusable; lets `page_ptr_unalign` avoid a hardware divide. + pub block_recip: Cell, + + _pad0: [u8; 88], + + // ---- line 1: the atomics foreign frees write ---- + pub xthread_free: AtomicUsize, + pub xheap: AtomicUsize, + + _pad1: [u8; 112], + + // ---- line 2: owner-thread hot fields ---- + pub free: Cell<*mut Block>, + pub local_free: Cell<*mut Block>, + pub used: Cell, + pub capacity: Cell, // blocks committed + pub reserved: Cell, // blocks reserved in memory + /// Bit 0: page is in the full queue; bit 1: contains aligned (interior) + /// blocks. One byte so the free fast path tests both with one load. + flags: Cell, + pub free_is_zero: Cell, + pub retire_expire: Cell, + + pub next: Cell<*mut Page>, + pub prev: Cell<*mut Page>, + + /// Random keys encoding the free lists (`secure` feature; `MI_ENCODE_FREELIST`). + pub keys: [Cell; 2], +} + +// SAFETY: pages are reachable from other threads, but those only touch the +// atomic fields (`xthread_free`, `xheap`) and the constant-while-shared +// fields (`page_start`, `block_size`, `block_size_shift`), mirroring +// mimalloc's discipline. +unsafe impl Sync for Page {} + +/// A slice span header is the same struct viewed differently. +pub type Slice = Page; + +/// Free-list pointer encoding (`mi_ptr_encode`/`mi_ptr_decode`): with the +/// `secure` feature, next pointers are stored as +/// `rotl(ptr ^ k1, k0 % BITS) + k0` with per-page (or per-heap) random +/// keys, detecting corrupted lists and making overwrites useless to an +/// attacker. `null` is substituted by the list owner's address so an +/// encoded null is not a key oracle. +#[inline(always)] +pub fn ptr_encode(null_sub: usize, p: *mut Block, keys: &[Cell; 2]) -> *mut Block { + if cfg!(feature = "secure") { + let x = if p.is_null() { null_sub } else { p.addr() }; + let (k0, k1) = (keys[0].get(), keys[1].get()); + ptr::with_exposed_provenance_mut( + (x ^ k1) + .rotate_left((k0 % usize::BITS as usize) as u32) + .wrapping_add(k0), + ) + } else { + p + } +} + +#[inline(always)] +pub fn ptr_decode(null_sub: usize, enc: *mut Block, keys: &[Cell; 2]) -> *mut Block { + if cfg!(feature = "secure") { + let (k0, k1) = (keys[0].get(), keys[1].get()); + let x = enc + .addr() + .wrapping_sub(k0) + .rotate_right((k0 % usize::BITS as usize) as u32) + ^ k1; + if x == null_sub { + ptr::null_mut() + } else { + ptr::with_exposed_provenance_mut(x) + } + } else { + enc + } +} + +impl Page { + #[allow(clippy::declare_interior_mutable_const)] // template value, never read in place + pub const EMPTY: Page = Page { + slice_count: Cell::new(0), + slice_offset: Cell::new(0), + is_committed: Cell::new(false), + is_zero_init: Cell::new(false), + is_huge: Cell::new(false), + capacity: Cell::new(0), + reserved: Cell::new(0), + flags: Cell::new(0), + free_is_zero: Cell::new(false), + retire_expire: Cell::new(0), + free: Cell::new(ptr::null_mut()), + local_free: Cell::new(ptr::null_mut()), + used: Cell::new(0), + block_size_shift: Cell::new(0), + heap_tag: Cell::new(0), + block_size: Cell::new(0), + page_start: Cell::new(ptr::null_mut()), + block_recip: Cell::new(0), + xthread_free: AtomicUsize::new(0), + xheap: AtomicUsize::new(0), + _pad0: [0; 88], + _pad1: [0; 112], + next: Cell::new(ptr::null_mut()), + prev: Cell::new(ptr::null_mut()), + keys: [Cell::new(0), Cell::new(0)], + }; + + /// Set `block_size` together with its fastmod reciprocal (valid for + /// `0 < b < 2^32`; otherwise `page_ptr_unalign` falls back to `%`). + pub fn set_block_size(&self, b: usize) { + debug_assert!(b > 0); + self.block_size.set(b); + self.block_recip.set(if (b as u64) < (1 << 32) { + u64::MAX / b as u64 + 1 + } else { + 0 + }); + } + + /// `_mi_page_segment`: pages live inside their segment, so mask the address. + /// The segment mapping's provenance is exposed at allocation, so the + /// masked address reconstructs a valid pointer. + #[inline(always)] + pub fn segment(&self) -> &Segment { + // SAFETY: a live page is embedded in the slices array of its + // SEGMENT_ALIGN-aligned segment whose provenance was exposed. + unsafe { &*ptr::with_exposed_provenance(ptr::from_ref(self).addr() & !SEGMENT_MASK) } + } + + #[inline(always)] + pub fn start(&self) -> *mut u8 { + debug_assert!(!self.page_start.get().is_null()); + self.page_start.get() + } + + /// `mi_page_thread_free` + #[inline(always)] + pub fn thread_free(&self) -> ThreadFree { + ThreadFree(self.xthread_free.load(Ordering::Relaxed)) + } + + /// `mi_page_heap` + #[inline(always)] + pub fn heap(&self) -> *mut Heap { + ptr::with_exposed_provenance_mut(self.xheap.load(Ordering::Relaxed)) + } + + /// `mi_page_set_heap` + #[inline] + pub fn set_heap(&self, heap: *mut Heap) { + debug_assert!(self.thread_free().delayed() != Delayed::Freeing); + self.xheap + .store(heap.expose_provenance(), Ordering::Release); + if !heap.is_null() { + // SAFETY: heap outlives its pages; tag is plain data. + self.heap_tag.set(unsafe { (*heap).tag.get() }); + } + } + + const FLAG_IN_FULL: u8 = 0x01; + const FLAG_HAS_ALIGNED: u8 = 0x02; + + #[inline(always)] + pub fn in_full(&self) -> bool { + self.flags.get() & Self::FLAG_IN_FULL != 0 + } + + #[inline(always)] + pub fn set_in_full(&self, v: bool) { + let f = self.flags.get() & !Self::FLAG_IN_FULL; + self.flags.set(f | if v { Self::FLAG_IN_FULL } else { 0 }); + } + + #[inline(always)] + pub fn has_aligned(&self) -> bool { + self.flags.get() & Self::FLAG_HAS_ALIGNED != 0 + } + + #[inline(always)] + pub fn set_has_aligned(&self, v: bool) { + let f = self.flags.get() & !Self::FLAG_HAS_ALIGNED; + self.flags + .set(f | if v { Self::FLAG_HAS_ALIGNED } else { 0 }); + } + + /// True if the local free path must take the generic route (full-queue + /// bookkeeping or interior aligned pointers): a single byte test. + #[inline(always)] + pub fn full_or_aligned(&self) -> bool { + self.flags.get() != 0 + } + + /// `mi_page_all_free`: needs an up-to-date `used` count. + #[inline(always)] + pub fn all_free(&self) -> bool { + self.used.get() == 0 + } + + /// `mi_page_has_any_available` + #[inline(always)] + pub fn has_any_available(&self) -> bool { + debug_assert!(self.reserved.get() > 0); + self.used.get() < self.reserved.get() || !self.thread_free().block().is_null() + } + + /// `mi_page_immediate_available` + #[inline(always)] + pub fn immediate_available(&self) -> bool { + !self.free.get().is_null() + } + + /// `mi_page_is_mostly_used`: more than 7/8th in use. + #[inline] + pub fn is_mostly_used(&self) -> bool { + let frac = self.reserved.get() / 8; + self.reserved.get() - self.used.get() <= frac + } + + #[inline(always)] + pub fn block_size(&self) -> usize { + debug_assert!(self.block_size.get() > 0); + self.block_size.get() + } + + /// `mi_page_usable_block_size`: without fixed padding. + #[inline(always)] + pub fn usable_block_size(&self) -> usize { + self.block_size() - PADDING_SIZE + } + + // ---- slice-span view (`mi_slice_t`) ---- + + /// `mi_slice_first`: the head of the span this slice belongs to. + #[inline] + pub fn slice_first(&self) -> &Slice { + // SAFETY: slice_offset is the byte distance back to the span head + // within the same (exposed) segment mapping. + let first: &Slice = unsafe { + &*ptr::with_exposed_provenance( + ptr::from_ref(self).addr() - self.slice_offset.get() as usize, + ) + }; + debug_assert!(first.slice_offset.get() == 0); + first + } + + /// Whether this span is in use as a page (`block_size == 1` marker for + /// used spans that are not pages is set by the segment code). + #[inline(always)] + pub fn slice_is_used(&self) -> bool { + self.block_size.get() > 0 + } + + /// `mi_block_next`: read a block's next pointer (decoding under `secure`). + /// + /// # Safety + /// `b` must be a live free-list node of this page. + #[inline(always)] + pub unsafe fn block_next(&self, b: *mut Block) -> *mut Block { + // SAFETY: per contract. + let raw = unsafe { (*b).next.get() }; + ptr_decode(ptr::from_ref(self).addr(), raw, &self.keys) + } + + /// `mi_block_set_next`: link a block into one of this page's free lists + /// (write-only: the block's memory may be uninitialized). + /// + /// # Safety + /// `b` must point to block-sized memory of this page owned by the caller. + #[inline(always)] + pub unsafe fn block_set_next(&self, b: *mut Block, next: *mut Block) { + let enc = ptr_encode(ptr::from_ref(self).addr(), next, &self.keys); + // SAFETY: per contract. + unsafe { Block::write_next(b, enc) }; + } +} + +const _: () = assert!(size_of::() == 384); +const _: () = assert!(core::mem::offset_of!(Page, xthread_free) == 128); +const _: () = assert!(core::mem::offset_of!(Page, free) == 256); diff --git a/vendor/rimalloc/src/page_ops.rs b/vendor/rimalloc/src/page_ops.rs new file mode 100644 index 00000000..e9537f23 --- /dev/null +++ b/vendor/rimalloc/src/page_ops.rs @@ -0,0 +1,610 @@ +//! The core of the allocator (port of `page.c`): page init/extend, free-list +//! collection, retire, and the generic allocation path. + +use core::ptr; +use core::sync::atomic::Ordering; + +use crate::constants::*; +use crate::heap::{Heap, PageQueue}; +use crate::page::{Block, Delayed, Page}; + +const MAX_RETIRE_CYCLES: u8 = 16; +const MAX_EXTEND_SIZE: usize = 4 * 1024; +const MIN_EXTEND: usize = 4; +const MAX_CANDIDATE_SEARCH: usize = 4; + +/// Block `i` of a page (`mi_page_block_at`). +#[inline(always)] +fn page_block_at(page_start: *mut u8, block_size: usize, i: usize) -> *mut Block { + // SAFETY: caller keeps i <= reserved; blocks lie inside the page area. + unsafe { page_start.add(i * block_size).cast() } +} + +// --------------------------------------------------------------------------- +// Delayed-free flag transitions +// --------------------------------------------------------------------------- + +/// `_mi_page_try_use_delayed_free` +pub fn page_try_use_delayed_free(page: &Page, delay: Delayed, override_never: bool) -> bool { + crate::protocol::try_use_delayed( + &page.xthread_free, + delay as usize, + override_never, + std::thread::yield_now, + ) +} + +/// `_mi_page_use_delayed_free` +pub fn page_use_delayed_free(page: &Page, delay: Delayed, override_never: bool) { + while !page_try_use_delayed_free(page, delay, override_never) { + std::thread::yield_now(); + } +} + +// --------------------------------------------------------------------------- +// Collect free lists +// --------------------------------------------------------------------------- + +/// `_mi_page_thread_free_collect`: atomically take the cross-thread free +/// list. The pushed count rides in the taken word, so the common case +/// (the malloc `free` list is exhausted — that is why we are collecting) +/// adopts the chain as-is without pointer-chasing remote cache lines; +/// only a non-empty `free` list requires walking to the tail to splice +/// into `local_free`. +fn page_thread_free_collect(page: &Page) { + let taken = crate::page::ThreadFree(crate::protocol::collect_take(&page.xthread_free)); + let head = taken.block(); + if head.is_null() { + return; + } + let count = taken.count(); + if count == 0 || count > page.capacity.get() as usize || count > page.used.get() as usize { + crate::error("corrupted thread-free list"); + return; + } + if page.free.get().is_null() { + page.free.set(head); + page.free_is_zero.set(false); + } else { + let mut tail = head; + for _ in 1..count { + // SAFETY: list nodes are free blocks in this page (ownership of + // the whole list was transferred by collect_take's AcqRel swap). + tail = unsafe { page.block_next(tail) }; + if tail.is_null() { + crate::error("corrupted thread-free list"); + return; + } + } + // SAFETY: tail is a live node of this page. + unsafe { page.block_set_next(tail, page.local_free.get()) }; + page.local_free.set(head); + } + page.used.set(page.used.get() - count as u16); +} + +/// Assert the Lean-proven counting invariant (`FreeList.lean::Inv`) on a +/// live page: `used + |free| + |local_free| = capacity <= reserved`. +/// Debug builds only — list walks are linear. +#[cfg(debug_assertions)] +pub fn page_assert_invariant(page: &Page) { + if page.capacity.get() == 0 { + return; // not initialized yet (fresh or cleared page) + } + let count = |mut p: *mut Block| { + let mut n = 0usize; + while !p.is_null() { + n += 1; + debug_assert!( + n <= page.capacity.get() as usize, + "free list longer than capacity" + ); + // SAFETY: list nodes are live blocks of this page. + p = unsafe { page.block_next(p) }; + } + n + }; + let free = count(page.free.get()); + let local = count(page.local_free.get()); + debug_assert_eq!( + page.used.get() as usize + free + local, + page.capacity.get() as usize, + "page counting invariant violated (used={} |free|={free} |local|={local} cap={})", + page.used.get(), + page.capacity.get(), + ); + debug_assert!(page.capacity.get() <= page.reserved.get()); +} + +/// `_mi_page_free_collect` +#[inline] +pub fn page_free_collect(page: &Page, force: bool) { + if force || !page.thread_free().block().is_null() { + page_thread_free_collect(page); + } + if !page.local_free.get().is_null() { + if page.free.get().is_null() { + page.free.set(page.local_free.get()); + page.local_free.set(ptr::null_mut()); + page.free_is_zero.set(false); + } else if force { + // append (linear); only on shutdown + // SAFETY: list nodes are free blocks in this page. + unsafe { + let mut tail = page.local_free.get(); + while !page.block_next(tail).is_null() { + tail = page.block_next(tail); + } + page.block_set_next(tail, page.free.get()); + } + page.free.set(page.local_free.get()); + page.local_free.set(ptr::null_mut()); + page.free_is_zero.set(false); + } + } + #[cfg(debug_assertions)] + page_assert_invariant(page); +} + +// --------------------------------------------------------------------------- +// Fresh pages and reclaim +// --------------------------------------------------------------------------- + +/// `_mi_page_reclaim` (from segment reclaim). +pub fn page_reclaim(heap: &Heap, page: &Page) { + debug_assert!(page.heap() == ptr::from_ref(heap).cast_mut()); + let pq = heap.page_queue(page.block_size()); + heap.page_queue_push(pq, page); +} + +/// `mi_page_fresh_alloc` +fn page_fresh_alloc<'a>( + heap: &'a Heap, + pq: Option<&PageQueue>, + block_size: usize, + page_alignment: usize, +) -> Option<&'a Page> { + let page = + crate::segment::segment_page_alloc(heap, block_size, page_alignment, heap.segments_tld())?; + let full_block_size = if pq.is_none() || page.is_huge.get() { + page.block_size() + } else { + block_size + }; + page_init(heap, page, full_block_size); + crate::stats::STATS.pages.increase(1); + if let Some(pq) = pq { + heap.page_queue_push(pq, page); + } + Some(page) +} + +/// `mi_page_fresh` +fn page_fresh<'a>(heap: &'a Heap, pq: &PageQueue) -> Option<&'a Page> { + page_fresh_alloc(heap, Some(pq), pq.block_size, 0) +} + +// --------------------------------------------------------------------------- +// Heap delayed free +// --------------------------------------------------------------------------- + +/// `_mi_heap_delayed_free_partial`: free blocks other threads pushed on our +/// heap's delayed list. Returns false if some were contended. +pub fn heap_delayed_free_partial(heap: &Heap) -> bool { + // Links of delayed blocks are encoded with their *page's* keys (set by + // `mt_push` via `block_set_next`); the page is recovered from the + // block address. + let link_page = |addr: usize| -> &Page { + // SAFETY: delayed blocks live in pages of segments owned by us. + unsafe { crate::segment::ptr_page(ptr::with_exposed_provenance(addr)) } + }; + crate::protocol::delayed_partial( + &heap.thread_delayed_free, + |addr| { + // SAFETY: we own the taken list. + unsafe { link_page(addr).block_next(ptr::with_exposed_provenance_mut(addr)) } + .expose_provenance() + }, + |addr, next| { + // SAFETY: re-queueing a block we own; write-only. + unsafe { + link_page(addr).block_set_next( + ptr::with_exposed_provenance_mut(addr), + ptr::with_exposed_provenance_mut(next), + ) + } + }, + |addr| crate::free::free_delayed_block(ptr::with_exposed_provenance_mut(addr)), + ) +} + +/// `_mi_heap_delayed_free_all` +pub fn heap_delayed_free_all(heap: &Heap) { + while !heap_delayed_free_partial(heap) { + std::thread::yield_now(); + } +} + +// --------------------------------------------------------------------------- +// Unfull, abandon, free, retire +// --------------------------------------------------------------------------- + +/// `_mi_page_unfull` +pub fn page_unfull(page: &Page) { + debug_assert!(page.in_full()); + if !page.in_full() { + return; + } + // SAFETY: called on the owning thread; the heap is live. + let heap = unsafe { &*page.heap() }; + let pqfull = &heap.pages[crate::bins::Bin::FULL.index()]; + heap.page_set_in_full(page, false); // to compute the right queue + let pq = heap.page_queue_of(page); + heap.page_set_in_full(page, true); + heap.page_queue_enqueue_from(pq, pqfull, page); +} + +/// `mi_page_to_full` +fn page_to_full(page: &Page, pq: &PageQueue) { + debug_assert!(!page.immediate_available() && !page.in_full()); + if page.in_full() { + return; + } + // SAFETY: owning thread; heap is live. + let heap = unsafe { &*page.heap() }; + heap.page_queue_enqueue_from(&heap.pages[crate::bins::Bin::FULL.index()], pq, page); + page_free_collect(page, false); // collect frees racing with the move +} + +/// `_mi_page_abandon` +pub fn page_abandon(page: &Page, pq: &PageQueue) { + // SAFETY: owning thread; heap is live. + let heap = unsafe { &*page.heap() }; + let segments_tld = heap.segments_tld(); + heap.page_queue_remove(pq, page); + debug_assert!(page.thread_free().delayed() == Delayed::NeverDelayedFree); + page.set_heap(ptr::null_mut()); + page.segment().page_abandon(page, segments_tld); +} + +/// `_mi_page_force_abandon` (used by `segments_try_abandon`, not yet ported) +pub fn page_force_abandon(page: &Page) { + // SAFETY: owning thread; heap is live. + let heap = unsafe { &*page.heap() }; + page_use_delayed_free(page, Delayed::NeverDelayedFree, false); + heap_delayed_free_all(heap); + if page.capacity.get() == 0 { + return; // freed already + } + let pq = heap.page_queue_of(page); + if page.all_free() { + page_free(page, pq, false); + } else { + page_abandon(page, pq); + } +} + +/// `_mi_page_free`: free a page with no used blocks. +pub fn page_free(page: &Page, pq: &PageQueue, force: bool) { + debug_assert!(page.all_free()); + debug_assert!(page.thread_free().delayed() != Delayed::Freeing); + page.set_has_aligned(false); + // SAFETY: owning thread; heap is live. + let heap = unsafe { &*page.heap() }; + let segments_tld = heap.segments_tld(); + heap.page_queue_remove(pq, page); + page.set_heap(ptr::null_mut()); + page.segment().page_free(page, force, segments_tld); +} + +/// `_mi_page_retire`: a page just became all-free; retire it lazily if it is +/// the only page in its queue (new allocations are likely coming). +pub fn page_retire(page: &Page) { + debug_assert!(page.all_free()); + page.set_has_aligned(false); + // SAFETY: owning thread; heap is live. + let heap = unsafe { &*page.heap() }; + let pq = heap.page_queue_of(page); + let bsize = page.block_size(); + if !pq.is_special() { + let page_ptr = ptr::from_ref(page).cast_mut(); + if pq.last.get() == page_ptr && pq.first.get() == page_ptr { + page.retire_expire.set(if bsize <= SMALL_OBJ_SIZE_MAX { + MAX_RETIRE_CYCLES + } else { + MAX_RETIRE_CYCLES / 4 + }); + let index = + (ptr::from_ref(pq).addr() - heap.pages.as_ptr().addr()) / size_of::(); + heap.page_retired_min + .set(heap.page_retired_min.get().min(index)); + heap.page_retired_max + .set(heap.page_retired_max.get().max(index)); + return; // don't free after all + } + } + page_free(page, pq, false); +} + +/// `_mi_heap_collect_retired` +pub fn heap_collect_retired(heap: &Heap, force: bool) { + let mut min = BIN_FULL; + let mut max = 0; + for bin in heap.page_retired_min.get()..=heap.page_retired_max.get().min(BIN_FULL) { + let pq = &heap.pages[bin]; + // SAFETY: queue nodes are live pages of this heap. + if let Some(page) = unsafe { pq.first.get().as_ref() } + && page.retire_expire.get() != 0 + { + if page.all_free() { + page.retire_expire.set(page.retire_expire.get() - 1); + if force || page.retire_expire.get() == 0 { + page_free(page, pq, force); + } else { + min = min.min(bin); + max = max.max(bin); + } + } else { + page.retire_expire.set(0); + } + } + } + heap.page_retired_min.set(min); + heap.page_retired_max.set(max); +} + +// --------------------------------------------------------------------------- +// Page init / extend +// --------------------------------------------------------------------------- + +/// `mi_page_free_list_extend`: build a sequential free list for blocks +/// `[capacity, capacity+extend)`. +fn page_free_list_extend(page: &Page, bsize: usize, extend: usize) { + debug_assert!(page.free.get().is_null() && page.local_free.get().is_null()); + let page_area = page.start(); + let start = page_block_at(page_area, bsize, page.capacity.get() as usize); + let last = page_block_at(page_area, bsize, page.capacity.get() as usize + extend - 1); + let mut block = start; + while block < last { + // SAFETY: fresh blocks within the page area; write-only since their + // memory is uninitialized. + let next = unsafe { block.cast::().add(bsize).cast::() }; + // SAFETY: `block` is a fresh allocator-owned block. + unsafe { page.block_set_next(block, next) }; + block = next; + } + // SAFETY: last is a fresh block; prepend to the existing free list. + unsafe { page.block_set_next(last, page.free.get()) }; + page.free.set(start); +} + +/// `mi_page_is_expandable` +#[inline] +fn page_is_expandable(page: &Page) -> bool { + debug_assert!(page.capacity.get() <= page.reserved.get()); + page.capacity.get() < page.reserved.get() +} + +/// `mi_page_extend_free`: extend capacity by initializing more free blocks +/// (at most one OS-page worth at a time to limit commit). +pub fn page_extend_free(page: &Page) -> bool { + if !page.free.get().is_null() { + return true; + } + if page.capacity.get() >= page.reserved.get() { + return true; + } + let bsize = page.block_size(); + let mut extend = (page.reserved.get() - page.capacity.get()) as usize; + let max_extend = if bsize >= MAX_EXTEND_SIZE { + MIN_EXTEND + } else { + (MAX_EXTEND_SIZE / bsize).max(MIN_EXTEND) + }; + extend = extend.min(max_extend); + crate::stats::STATS + .pages_extended + .fetch_add(1, Ordering::Relaxed); + page_free_list_extend(page, bsize, extend); + page.capacity.set(page.capacity.get() + extend as u16); + true +} + +/// `mi_page_init` +fn page_init(heap: &Heap, page: &Page, block_size: usize) { + let segment = page.segment(); + page.set_heap(ptr::from_ref(heap).cast_mut()); + page.set_block_size(block_size); + let (start, page_size) = segment.page_start(page); + page.page_start.set(start); + debug_assert!(block_size <= page_size); + debug_assert!(page_size / block_size < (1 << 16)); + page.reserved.set((page_size / block_size) as u16); + debug_assert!(page.reserved.get() > 0); + page.free_is_zero.set(page.is_zero_init.get()); + page.keys[0].set(crate::init::random_next() | 1); + page.keys[1].set(crate::init::random_next() | 1); + page.block_size_shift.set(if block_size.is_power_of_two() { + block_size.trailing_zeros() as u8 + } else { + 0 + }); + debug_assert!(page.capacity.get() == 0 && page.free.get().is_null()); + debug_assert!(page.used.get() == 0 && page.next.get().is_null()); + page_extend_free(page); +} + +// --------------------------------------------------------------------------- +// Find pages with free blocks +// --------------------------------------------------------------------------- + +/// `mi_page_queue_find_free_ex`: next-fit search with candidate preference. +fn page_queue_find_free_ex<'a>( + heap: &'a Heap, + pq: &PageQueue, + first_try: bool, +) -> Option<&'a Page> { + let mut candidate_count = 0; + let mut page_candidate: Option<&Page> = None; + let mut p = pq.first.get(); + + // SAFETY: queue nodes are live pages of this heap. + while let Some(page) = unsafe { p.as_ref() } { + let next = page.next.get(); + candidate_count += 1; + page_free_collect(page, false); + let immediate_available = page.immediate_available(); + + if !immediate_available && !page_is_expandable(page) { + // full: move to the full queue so we don't revisit it + page_to_full(page, pq); + } else { + match page_candidate { + None => { + page_candidate = Some(page); + candidate_count = 0; + } + // prefer to reuse fuller pages (so emptier ones can free up) + Some(c) + if page.used.get() >= c.used.get() + && !page.is_mostly_used() + && !page_is_expandable(page) => + { + page_candidate = Some(page); + } + _ => {} + } + if immediate_available || candidate_count > MAX_CANDIDATE_SEARCH { + break; + } + } + p = next; + } + + let mut page = page_candidate.or( + // SAFETY: p is either null or a live page. + unsafe { p.as_ref() }, + ); + if let Some(pg) = page + && !pg.immediate_available() + { + debug_assert!(page_is_expandable(pg)); + if !page_extend_free(pg) { + page = None; + } + } + + match page { + None => { + heap_collect_retired(heap, false); // perhaps make a page available + let page = page_fresh(heap, pq); + if page.is_none() && first_try { + // out-of-memory _or_ reclaimed an abandoned page with blocks + page_queue_find_free_ex(heap, pq, false) + } else { + page + } + } + Some(page) => { + heap.page_queue_move_to_front(pq, page); + page.retire_expire.set(0); + Some(page) + } + } +} + +/// `mi_find_free_page` +fn find_free_page(heap: &Heap, size: usize) -> Option<&Page> { + let pq = heap.page_queue(size); + // SAFETY: queue nodes are live pages of this heap. + if let Some(page) = unsafe { pq.first.get().as_ref() } { + page_free_collect(page, false); + if page.immediate_available() { + page.retire_expire.set(0); + return Some(page); + } + } + page_queue_find_free_ex(heap, pq, true) +} + +// --------------------------------------------------------------------------- +// General allocation +// --------------------------------------------------------------------------- + +/// `mi_large_huge_page_alloc` +fn large_huge_page_alloc(heap: &Heap, size: usize, page_alignment: usize) -> Option<&Page> { + let block_size = crate::os::good_alloc_size(size); + let is_huge = block_size > LARGE_OBJ_SIZE_MAX || page_alignment > 0; + let pq = heap.page_queue(if is_huge { + LARGE_OBJ_SIZE_MAX + 1 + } else { + block_size + }); + debug_assert!(!is_huge || pq.is_huge()); + page_fresh_alloc(heap, Some(pq), block_size, page_alignment) +} + +/// `mi_find_page` +fn find_page(heap: &Heap, size: usize, huge_alignment: usize) -> Option<&Page> { + let req_size = size - PADDING_SIZE; + if req_size > MEDIUM_OBJ_SIZE_MAX - PADDING_SIZE || huge_alignment > 0 { + if req_size > MAX_ALLOC_SIZE { + crate::error("allocation request is too large"); + None + } else { + large_huge_page_alloc(heap, size, huge_alignment) + } + } else { + find_free_page(heap, size) + } +} + +/// `_mi_malloc_generic`: the slow path behind the malloc fast path. +pub fn malloc_generic( + heap: &Heap, + size: usize, + zero: bool, + huge_alignment: usize, +) -> Option> { + crate::os::drain_deferred(); + + // initialize if necessary + let heap = if !heap.is_initialized() { + crate::init::heap_default() + } else { + heap + }; + + // administrative tasks every N generic calls + heap.generic_count.set(heap.generic_count.get() + 1); + if heap.generic_count.get() >= 100 { + heap.generic_collect_count + .set(heap.generic_collect_count.get() + heap.generic_count.get()); + heap.generic_count.set(0); + crate::hooks::deferred_free(heap, false); + heap_delayed_free_partial(heap); + if heap.generic_collect_count.get() >= 10000 { + heap.generic_collect_count.set(0); + crate::heap_ops::heap_collect(heap, false); + } + } + + let page = match find_page(heap, size, huge_alignment) { + Some(p) => Some(p), + None => { + // out of memory: collect hard and retry once + crate::heap_ops::heap_collect(heap, true); + find_page(heap, size, huge_alignment) + } + }?; + + debug_assert!(page.immediate_available()); + debug_assert!(page.block_size() >= size); + + crate::stats::stat_malloc(page.usable_block_size(), page.is_huge.get()); + let p = crate::alloc::page_malloc_zero(heap, page, size, zero); + if page.reserved.get() == page.used.get() { + page_to_full(page, heap.page_queue_of(page)); + } + p +} diff --git a/vendor/rimalloc/src/protocol.rs b/vendor/rimalloc/src/protocol.rs new file mode 100644 index 00000000..230ec414 --- /dev/null +++ b/vendor/rimalloc/src/protocol.rs @@ -0,0 +1,217 @@ +//! The cross-thread delayed-free protocol — the *single* implementation, +//! generic over the atomic word type. Production (`free.rs`, +//! `page_ops.rs`) instantiates it with `core` atomics on the live +//! `Page`/`Heap` fields; `tests/loom_protocol.rs` instantiates the *same +//! functions* with loom atomics, so every interleaving of exactly this +//! code is model-checked. Block storage (next links) is abstracted as +//! read/write closures because blocks live in raw page memory in +//! production and in instrumented cells under loom; all CAS structure, +//! memory orderings, and flag transitions live here and only here. + +use core::sync::atomic::Ordering; + +pub const USE_DELAYED: usize = 0; +pub const FREEING: usize = 1; +pub const NO_DELAYED: usize = 2; +pub const NEVER_DELAYED: usize = 3; +pub const FLAG_MASK: usize = 3; + +/// The thread-free word packs `count << 48 | block_ptr | delayed_flags`: +/// pushers increment the count so the owner's collect learns the list +/// length without walking it (pointer-chasing remote cache lines). User +/// addresses fit in 48 bits on all supported targets, and the count cannot +/// exceed a page's block capacity (< 2^16). +pub const TF_COUNT_UNIT: usize = 1 << TF_COUNT_SHIFT; +pub const TF_COUNT_SHIFT: u32 = 48; +pub const TF_PTR_MASK: usize = TF_COUNT_UNIT - 1 - FLAG_MASK; + +/// Minimal atomic-word interface implemented by `core` and loom atomics. +pub trait AtomicWord { + fn load(&self, order: Ordering) -> usize; + fn store(&self, val: usize, order: Ordering); + fn swap(&self, val: usize, order: Ordering) -> usize; + fn compare_exchange_weak( + &self, + current: usize, + new: usize, + success: Ordering, + failure: Ordering, + ) -> Result; +} + +impl AtomicWord for core::sync::atomic::AtomicUsize { + fn load(&self, order: Ordering) -> usize { + self.load(order) + } + fn store(&self, val: usize, order: Ordering) { + self.store(val, order) + } + fn swap(&self, val: usize, order: Ordering) -> usize { + self.swap(val, order) + } + fn compare_exchange_weak( + &self, + current: usize, + new: usize, + success: Ordering, + failure: Ordering, + ) -> Result { + self.compare_exchange_weak(current, new, success, failure) + } +} + +/// `mi_free_block_delayed_mt`: push a foreign-owned block (`block` is its +/// address-with-provenance-exposed) onto the page's thread-free list, or — +/// on the first concurrent free into a full page — onto the owning heap's +/// delayed list, guarded by the FREEING handshake. `write_next` stores a +/// next-link into the (exclusively owned) dying block without reading its +/// old contents. +pub fn mt_push<'d, A: AtomicWord, D: AtomicWord + 'd>( + xthread: &A, + delayed: impl FnOnce() -> Option<&'d D>, + block: usize, + mut write_next: impl FnMut(usize), +) { + debug_assert!(block & !TF_PTR_MASK == 0 && block != 0); + let mut tfree = xthread.load(Ordering::Relaxed); + let use_delayed = loop { + let use_delayed = tfree & FLAG_MASK == USE_DELAYED; + let tfreex = if use_delayed { + (tfree & !FLAG_MASK) | FREEING + } else { + write_next(tfree & TF_PTR_MASK); + block | (tfree & !TF_PTR_MASK).wrapping_add(TF_COUNT_UNIT) + }; + match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Relaxed) { + Ok(_) => break use_delayed, + Err(v) => tfree = v, + } + }; + if use_delayed { + // First concurrent free into a full page: push on the heap's + // delayed list (the heap stays valid while FREEING is held). + if let Some(dlist) = delayed() { + let mut dfree = dlist.load(Ordering::Relaxed); + loop { + write_next(dfree); + match dlist.compare_exchange_weak( + dfree, + block, + Ordering::Release, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(v) => dfree = v, + } + } + } + // reset FREEING to NO_DELAYED + let mut tfree = xthread.load(Ordering::Relaxed); + loop { + debug_assert!(tfree & FLAG_MASK == FREEING); + let tfreex = (tfree & !FLAG_MASK) | NO_DELAYED; + match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Relaxed) + { + Ok(_) => break, + Err(v) => tfree = v, + } + } + } +} + +/// `_mi_page_thread_free_collect`'s atomic half: take the whole +/// thread-free list (head pointer *and* count), keeping the delayed flag +/// bits. Returns the taken word: decode with `TF_PTR_MASK` / +/// `TF_COUNT_SHIFT`. +pub fn collect_take(xthread: &A) -> usize { + let mut tfree = xthread.load(Ordering::Relaxed); + loop { + match xthread.compare_exchange_weak( + tfree, + tfree & FLAG_MASK, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return tfree & !FLAG_MASK, + Err(v) => tfree = v, + } + } +} + +/// `_mi_page_try_use_delayed_free`: transition the delayed flag, spinning +/// (boundedly) while another thread holds FREEING. +pub fn try_use_delayed( + xthread: &A, + delay: usize, + override_never: bool, + mut yield_now: impl FnMut(), +) -> bool { + let mut yield_count = 0; + let mut tfree = xthread.load(Ordering::Acquire); + loop { + let old_delay = tfree & FLAG_MASK; + if old_delay == FREEING { + if yield_count >= 4 { + return false; + } + yield_count += 1; + yield_now(); + tfree = xthread.load(Ordering::Acquire); + continue; + } + if delay == old_delay { + return true; + } + if !override_never && old_delay == NEVER_DELAYED { + return true; + } + let tfreex = (tfree & !FLAG_MASK) | delay; + match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Acquire) { + Ok(_) => return true, + Err(v) => tfree = v, + } + } +} + +/// `_mi_heap_delayed_free_partial`'s atomic shell: take the heap's delayed +/// list, process each block with `process` (which returns `false` when +/// contended), and re-queue failures. `read_next` reads the link of a +/// block we own; `write_next` re-links one for re-queueing. Returns true +/// if everything was freed. +pub fn delayed_partial( + delayed: &D, + mut read_next: impl FnMut(usize) -> usize, + mut write_next: impl FnMut(usize, usize), + mut process: impl FnMut(usize) -> bool, +) -> bool { + let mut block = delayed.load(Ordering::Relaxed); + while block != 0 { + match delayed.compare_exchange_weak(block, 0, Ordering::AcqRel, Ordering::Relaxed) { + Ok(_) => break, + Err(v) => block = v, + } + } + let mut all_freed = true; + while block != 0 { + let next = read_next(block); + if !process(block) { + // another thread still holds FREEING: requeue + all_freed = false; + let mut dfree = delayed.load(Ordering::Relaxed); + loop { + write_next(block, dfree); + match delayed.compare_exchange_weak( + dfree, + block, + Ordering::Release, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(v) => dfree = v, + } + } + } + block = next; + } + all_freed +} diff --git a/vendor/rimalloc/src/segment.rs b/vendor/rimalloc/src/segment.rs new file mode 100644 index 00000000..4991fcc2 --- /dev/null +++ b/vendor/rimalloc/src/segment.rs @@ -0,0 +1,1852 @@ +//! Segments (port of `segment.c`): 32MiB aligned chunks divided into 64KiB +//! slices. Pages are spans of slices; free spans are kept in per-thread span +//! queues binned by slice count. The segment header (including the `slices` +//! metadata array) occupies the first slices ("info slices"). + +use core::cell::Cell; +use core::ptr; +use core::sync::atomic::{AtomicUsize, Ordering}; + +use crate::constants::*; +use crate::heap::Heap; +use crate::options; +use crate::os::{self, OsMem}; +use crate::page::{Page, Slice}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SegmentKind { + Normal, // SEGMENT_SIZE with pages inside + Huge, // one huge page, variable size +} + +/// `mi_commit_mask_t`: one bit per commit chunk (= slice). +pub struct CommitMask { + mask: [Cell; COMMIT_MASK_FIELD_COUNT], +} + +impl CommitMask { + #[allow(clippy::declare_interior_mutable_const)] // template value, never read in place + pub const EMPTY: CommitMask = CommitMask { + mask: [const { Cell::new(0) }; COMMIT_MASK_FIELD_COUNT], + }; + + pub fn clear_all(&self) { + for f in &self.mask { + f.set(0); + } + } + pub fn set_full(&self) { + for f in &self.mask { + f.set(!0); + } + } + pub fn is_empty(&self) -> bool { + self.mask.iter().all(|f| f.get() == 0) + } + pub fn is_full(&self) -> bool { + self.mask.iter().all(|f| f.get() == !0) + } + /// Are all bits of `other` set in `self`? + pub fn all_set(&self, other: &CommitMask) -> bool { + self.mask + .iter() + .zip(&other.mask) + .all(|(a, b)| a.get() & b.get() == b.get()) + } + pub fn any_set(&self, other: &CommitMask) -> bool { + self.mask + .iter() + .zip(&other.mask) + .any(|(a, b)| a.get() & b.get() != 0) + } + pub fn set(&self, other: &CommitMask) { + for (a, b) in self.mask.iter().zip(&other.mask) { + a.set(a.get() | b.get()); + } + } + pub fn clear(&self, other: &CommitMask) { + for (a, b) in self.mask.iter().zip(&other.mask) { + a.set(a.get() & !b.get()); + } + } + pub fn intersect(&self, other: &CommitMask) -> CommitMask { + let r = CommitMask::EMPTY; + for ((a, b), c) in self.mask.iter().zip(&other.mask).zip(&r.mask) { + c.set(a.get() & b.get()); + } + r + } + pub fn copy_from(&self, other: &CommitMask) { + for (a, b) in self.mask.iter().zip(&other.mask) { + a.set(b.get()); + } + } + /// `mi_commit_mask_create`: set `count` bits starting at `bitidx`. + pub fn create(bitidx: usize, bitcount: usize) -> CommitMask { + debug_assert!(bitidx + bitcount <= COMMIT_MASK_BITS); + let cm = CommitMask::EMPTY; + if bitcount == COMMIT_MASK_BITS { + cm.set_full(); + } else if bitcount > 0 { + let mut field = bitidx / COMMIT_MASK_FIELD_BITS; + let mut ofs = bitidx % COMMIT_MASK_FIELD_BITS; + let mut left = bitcount; + while left > 0 { + let avail = COMMIT_MASK_FIELD_BITS - ofs; + let count = left.min(avail); + let mask = if count == COMMIT_MASK_FIELD_BITS { + !0 + } else { + ((1usize << count) - 1) << ofs + }; + cm.mask[field].set(cm.mask[field].get() | mask); + left -= count; + ofs = 0; + field += 1; + } + } + cm + } + /// Raw field words (for the Lean conformance bridge). + pub fn to_words(&self) -> [usize; COMMIT_MASK_FIELD_COUNT] { + core::array::from_fn(|i| self.mask[i].get()) + } + + /// Iterate maximal runs of set bits as `(bit_index, bit_count)`. + pub fn for_each_run(&self, mut f: impl FnMut(usize, usize)) { + let mut idx = 0; + while idx < COMMIT_MASK_BITS { + // find next set bit + while idx < COMMIT_MASK_BITS + && self.mask[idx / COMMIT_MASK_FIELD_BITS].get() + & (1 << (idx % COMMIT_MASK_FIELD_BITS)) + == 0 + { + idx += 1; + } + let start = idx; + while idx < COMMIT_MASK_BITS + && self.mask[idx / COMMIT_MASK_FIELD_BITS].get() + & (1 << (idx % COMMIT_MASK_FIELD_BITS)) + != 0 + { + idx += 1; + } + if idx > start { + f(start, idx - start); + } + } + } +} + +/// Where a segment's memory came from (`mi_memkind_t`). +#[derive(Clone, Copy, Debug)] +pub enum MemSource { + Os, + Arena(crate::arena::ArenaRef), +} + +/// Provenance of segment memory (`mi_memid_t`). +#[derive(Clone, Copy, Debug)] +pub struct MemId { + pub mem: OsMem, + pub source: MemSource, + pub is_pinned: bool, + pub initially_committed: bool, + pub initially_zero: bool, +} + +/// `mi_segment_t`. The struct is followed (within the same allocation) by +/// nothing: `slices` is a fixed array as in C. +#[repr(C)] +pub struct Segment { + pub memid: Cell, + pub allow_decommit: Cell, + pub allow_purge: Cell, + pub segment_size: Cell, + + pub purge_expire: Cell, + pub purge_mask: CommitMask, + pub commit_mask: CommitMask, + + pub next: Cell<*mut Segment>, // intrusive: abandoned list + pub was_reclaimed: Cell, + pub dont_free: Cell, + pub free_is_zero: Cell, + + pub abandoned: Cell, // abandoned pages (<= used) + pub abandoned_visits: Cell, + pub used: Cell, // pages in use + pub cookie: Cell, + + pub abandoned_os_next: Cell<*mut Segment>, + pub abandoned_os_prev: Cell<*mut Segment>, + pub abandoned_os_listed: Cell, + + pub segment_slices: Cell, + pub segment_info_slices: Cell, + + pub kind: Cell, + pub slice_entries: Cell, + pub thread_id: AtomicUsize, + + pub slices: [Slice; SLICES_PER_SEGMENT + 1], +} + +/// The block-area start offset within a page (pure core of +/// `_mi_segment_page_start_from_slice`): nudge the start off OS-page/cache +/// boundaries while keeping it `MAX_ALIGN_SIZE`-aligned and, for blocks up +/// to `MAX_ALIGN_GUARANTEE`, block-size aligned. Modeled and verified in +/// `verify/RimallocVerify/PageStart.lean`; the live function is pinned to +/// the proven contract in `tests/lean_conformance.rs`. +pub(crate) fn page_start_offset(block_size: usize, pstart_addr: usize, psize: usize) -> usize { + let mut start_offset = 0; + if block_size > 0 && block_size <= MAX_ALIGN_GUARANTEE { + let adjust = block_size - (pstart_addr % block_size); + if adjust < block_size && psize >= block_size + adjust { + start_offset += adjust; + } + } + if block_size >= PTR_SIZE { + if block_size <= 64 { + start_offset += 3 * block_size; + } else if block_size <= 512 { + start_offset += block_size; + } + } + align_up(start_offset, MAX_ALIGN_SIZE) +} + +/// `_mi_ptr_segment`: the segment containing `p` (which must point into one). +/// +/// # Safety +/// `p` must point into a live segment (one byte past the start is ok). +#[inline(always)] +pub unsafe fn ptr_segment<'a>(p: *const u8) -> &'a Segment { + debug_assert!(!p.is_null()); + // SAFETY: per contract; align down to the SEGMENT_SIZE boundary + // (one byte before p, for blocks aligned at N*SEGMENT_SIZE). The + // segment mapping's provenance was exposed at allocation. + unsafe { &*ptr::with_exposed_provenance((p.addr() - 1) & !SEGMENT_MASK) } +} + +/// `_mi_ptr_page`: the page containing `p`. +/// +/// # Safety +/// `p` must point into the block area of a live page. +#[inline(always)] +pub unsafe fn ptr_page<'a>(p: *const u8) -> &'a Page { + // SAFETY: per contract. + let segment = unsafe { ptr_segment(p) }; + segment.page_of(p) +} + +impl Segment { + /// `_mi_arena_memid_is_suitable` on this segment's memory. + pub fn memid_suitable(&self, req: crate::arena::ArenaId) -> bool { + match self.memid.get().source { + MemSource::Arena(aref) => crate::arena::ref_is_suitable(aref, req), + MemSource::Os => req == crate::arena::ARENA_ID_NONE, + } + } + + /// Base address of the segment mapping, carrying provenance over the + /// whole (exposed) mapping rather than just the header struct. + #[inline(always)] + pub fn as_ptr(&self) -> *mut u8 { + ptr::with_exposed_provenance_mut(ptr::from_ref(self).addr()) + } + + #[inline(always)] + pub fn size(&self) -> usize { + self.segment_slices.get() * SEGMENT_SLICE_SIZE + } + + #[inline] + pub fn info_size(&self) -> usize { + self.segment_info_slices.get() * SEGMENT_SLICE_SIZE + } + + #[inline(always)] + pub fn is_abandoned(&self) -> bool { + self.thread_id.load(Ordering::Relaxed) == 0 + } + + #[inline] + pub fn slice_index(&self, slice: &Slice) -> usize { + let index = + (ptr::from_ref(slice).addr() - self.slices.as_ptr().addr()) / size_of::(); + debug_assert!(index < self.slice_entries.get()); + index + } + + /// `mi_slice_start`: address of the slice's memory. + #[inline] + pub fn slice_start(&self, slice: &Slice) -> *mut u8 { + // SAFETY: slices map 1:1 onto the segment's memory. + unsafe { + self.as_ptr() + .add(self.slice_index(slice) * SEGMENT_SLICE_SIZE) + } + } + + /// One past the last usable slice entry. + #[inline] + pub fn slices_end(&self) -> *const Slice { + // SAFETY: slice_entries <= SLICES_PER_SEGMENT+1. + unsafe { self.slices.as_ptr().add(self.slice_entries.get()) } + } + + /// `_mi_segment_page_of`: the page holding `p` (interior pointers ok). + #[inline(always)] + pub fn page_of(&self, p: *const u8) -> &Page { + let diff = p.addr() - self.as_ptr().addr(); + debug_assert!(diff > 0 && diff <= SEGMENT_SIZE); + let idx = diff >> SEGMENT_SLICE_SHIFT; + debug_assert!(idx <= self.slice_entries.get()); + let slice = &self.slices[idx]; + let first = slice.slice_first(); + debug_assert!(first.slice_offset.get() == 0); + first + } + + /// `_mi_segment_page_start_from_slice`: start of a page's block area. + /// The start is offset to avoid OS-page/cache alignment effects while + /// staying block-size aligned for blocks up to `MAX_ALIGN_GUARANTEE`. + fn page_start_from_slice(&self, slice: &Slice, block_size: usize) -> (*mut u8, usize) { + let idx = self.slice_index(slice); + let psize = slice.slice_count.get() as usize * SEGMENT_SLICE_SIZE; + let pstart_addr = self.as_ptr().addr() + idx * SEGMENT_SLICE_SIZE; + let start_offset = page_start_offset(block_size, pstart_addr, psize); + // SAFETY: start_offset < psize, within the segment. + let pstart = unsafe { self.as_ptr().add(idx * SEGMENT_SLICE_SIZE + start_offset) }; + (pstart, psize - start_offset) + } + + /// `_mi_segment_page_start` + pub fn page_start(&self, page: &Page) -> (*mut u8, usize) { + self.page_start_from_slice(page, page.block_size.get()) + } + + // ---- commit / purge ---- + + /// `mi_segment_commit_mask` + fn commit_range_mask( + &self, + conservative: bool, + p: *mut u8, + size: usize, + ) -> (*mut u8, usize, CommitMask) { + debug_assert!(self.kind.get() != SegmentKind::Huge); + if size == 0 || size > SEGMENT_SIZE { + return (p, 0, CommitMask::EMPTY); + } + let segstart = self.info_size(); + let segsize = self.size(); + if p.addr() >= self.as_ptr().addr() + segsize { + return (p, 0, CommitMask::EMPTY); + } + let pstart = p.addr() - self.as_ptr().addr(); + debug_assert!(pstart + size <= segsize); + + let (mut start, mut end) = if conservative { + ( + align_up(pstart, COMMIT_SIZE), + align_down(pstart + size, COMMIT_SIZE), + ) + } else { + ( + align_down(pstart, MINIMAL_COMMIT_SIZE), + align_up(pstart + size, MINIMAL_COMMIT_SIZE), + ) + }; + if pstart >= segstart && start < segstart { + start = segstart; + } + end = end.min(segsize); + + let full_size = end.saturating_sub(start); + if full_size == 0 { + return (p, 0, CommitMask::EMPTY); + } + let bitidx = start / COMMIT_SIZE; + let bitcount = full_size / COMMIT_SIZE; + ( + self.as_ptr().with_addr(self.as_ptr().addr() + start), + full_size, + CommitMask::create(bitidx, bitcount), + ) + } + + /// `mi_segment_commit` + fn commit(&self, p: *mut u8, size: usize) -> bool { + let (start, full_size, mask) = self.commit_range_mask(false, p, size); + if mask.is_empty() || full_size == 0 { + return true; + } + if !self.commit_mask.all_set(&mask) { + // SAFETY: range lies within this segment's mapping. + unsafe { os::commit(start, full_size) }; + self.commit_mask.set(&mask); + } + if self.purge_mask.any_set(&mask) { + self.purge_expire + .set(options::clock_now() + options::purge_delay()); + } + self.purge_mask.clear(&mask); + true + } + + /// `mi_segment_ensure_committed` + pub fn ensure_committed(&self, p: *mut u8, size: usize) -> bool { + if self.commit_mask.is_full() && self.purge_mask.is_empty() { + return true; + } + debug_assert!(self.kind.get() != SegmentKind::Huge); + let ok = self.commit(p, size); + debug_assert!( + !ok || { + let (_, fs, mask) = self.commit_range_mask(false, p, size); + fs == 0 || self.commit_mask.all_set(&mask) + }, + "ensure_committed left range uncovered" + ); + ok + } + + /// `mi_segment_purge` + fn purge(&self, p: *mut u8, size: usize) { + if !self.allow_purge.get() { + return; + } + let (start, full_size, mask) = self.commit_range_mask(true, p, size); + if mask.is_empty() || full_size == 0 { + return; + } + if self.commit_mask.any_set(&mask) { + debug_assert!(self.allow_decommit.get()); + if options::purge_decommits() != 0 { + // SAFETY: purged ranges hold no live blocks (callers + // guarantee); decommitted ranges recommit on next use. + unsafe { os::decommit(start, full_size) }; + self.commit_mask.clear(&mask); + } else { + // SAFETY: purged ranges hold no live blocks (callers guarantee). + unsafe { os::reset(start, full_size) }; + } + } + self.purge_mask.clear(&mask); + } + + /// `mi_segment_schedule_purge` + fn schedule_purge(&self, p: *mut u8, size: usize) { + if !self.allow_purge.get() { + return; + } + if options::purge_delay() == 0 { + self.purge(p, size); + return; + } + let (_, full_size, mask) = self.commit_range_mask(true, p, size); + if mask.is_empty() || full_size == 0 { + return; + } + let cmask = self.commit_mask.intersect(&mask); // only purge committed parts + self.purge_mask.set(&cmask); + let now = options::clock_now(); + let expire = self.purge_expire.get(); + if expire == 0 { + self.purge_expire.set(now + options::purge_delay()); + } else if expire <= now { + if expire + options::purge_extend_delay() <= now { + self.try_purge(true); + } else { + self.purge_expire.set(now + options::purge_extend_delay()); + } + } else { + self.purge_expire + .set(expire + options::purge_extend_delay()); + } + } + + /// `mi_segment_try_purge` + pub fn try_purge(&self, force: bool) { + if !self.allow_purge.get() || self.purge_expire.get() == 0 || self.purge_mask.is_empty() { + return; + } + if !force && options::clock_now() < self.purge_expire.get() { + return; + } + let mask = CommitMask::EMPTY; + mask.copy_from(&self.purge_mask); + self.purge_expire.set(0); + self.purge_mask.clear_all(); + mask.for_each_run(|idx, count| { + // SAFETY: bit ranges map within the segment. + let p = unsafe { self.as_ptr().add(idx * COMMIT_SIZE) }; + self.purge(p, count * COMMIT_SIZE); + }); + } +} + +// --------------------------------------------------------------------------- +// Span queues (free slice spans, per thread) +// --------------------------------------------------------------------------- + +/// `mi_span_queue_t` +pub struct SpanQueue { + pub first: Cell<*mut Slice>, + pub last: Cell<*mut Slice>, + pub slice_count: usize, +} + +/// Max slice count per span bin (`MI_SEGMENT_SPAN_QUEUES_EMPTY`). +pub const SPAN_QUEUE_SIZES: [usize; SEGMENT_BIN_MAX + 1] = [ + 1, 1, 2, 3, 4, 5, 6, 7, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, + 192, 224, 256, 320, 384, 448, 512, 640, 768, 896, 1024, +]; + +/// `mi_slice_bin`: bin for a span of `slice_count` slices. +#[inline] +fn slice_bin(slice_count: usize) -> usize { + debug_assert!(slice_count * SEGMENT_SLICE_SIZE <= SEGMENT_SIZE); + if slice_count <= 1 { + return slice_count; + } + let n = slice_count - 1; + let s = (usize::BITS - 1 - n.leading_zeros()) as usize; // bsr, n > 0 + let bin = if s <= 2 { + n + 1 + } else { + ((s << 2) | ((n >> (s - 2)) & 0x03)) - 4 + }; + debug_assert!(bin <= SEGMENT_BIN_MAX); + bin +} + +/// Per-thread segment data (`mi_segments_tld_t`). +pub struct SegmentsTld { + pub spans: [SpanQueue; SEGMENT_BIN_MAX + 1], + pub count: Cell, + pub peak_count: Cell, + pub current_size: Cell, + pub peak_size: Cell, + pub reclaim_count: Cell, +} + +impl SegmentsTld { + pub fn new() -> SegmentsTld { + SegmentsTld { + spans: core::array::from_fn(|i| SpanQueue { + first: Cell::new(ptr::null_mut()), + last: Cell::new(ptr::null_mut()), + slice_count: SPAN_QUEUE_SIZES[i], + }), + count: Cell::new(0), + peak_count: Cell::new(0), + current_size: Cell::new(0), + peak_size: Cell::new(0), + reclaim_count: Cell::new(0), + } + } + + /// `mi_span_queue_for` + fn span_queue_for(&self, slice_count: usize) -> &SpanQueue { + let sq = &self.spans[slice_bin(slice_count)]; + debug_assert!(sq.slice_count >= slice_count); + sq + } + + fn track_size(&self, delta: isize) { + // `committed` is tracked at whole-segment granularity here, the one + // place segments enter and leave. Sub-segment lazy commit and + // decommit-purge are not reflected. + // + // A *globally exact* committed counter remains a rats' nest: freed OS + // segments flow through the global segment cache and the deferred-free + // list as bare `OsMem` records that have already shed the segment's + // `commit_mask`, and `cache_push`/`defer_free`/`cache_purge_expired` + // recommit, reset, and munmap whole regions in contexts that cannot + // cheaply recover the precise committed-byte delta (mprotect/mmap give + // no prior-state feedback). Tracking the true delta there would mean + // threading committed-byte counts back through the cache/defer layer — + // exactly the cross-layer state duplication that produced the original + // underflow. The coarse, whole-segment counter is instead exactly + // balanced by construction: every increase is paired with an equal + // decrease at the same granularity, so it can never underflow, in + // either reset or decommit purge mode (asserted in + // `tests/committed_stat.rs`). + if delta >= 0 { + crate::stats::STATS.segments.increase(1); + crate::stats::STATS.reserved.increase(delta as usize); + crate::stats::STATS.committed.increase(delta as usize); + } else { + crate::stats::STATS.segments.decrease(1); + crate::stats::STATS.reserved.decrease((-delta) as usize); + crate::stats::STATS.committed.decrease((-delta) as usize); + } + let count = self.count.get(); + self.count.set(count.wrapping_add_signed(delta.signum())); + self.peak_count + .set(self.peak_count.get().max(self.count.get())); + let size = self.current_size.get().wrapping_add_signed(delta); + self.current_size.set(size); + self.peak_size.set(self.peak_size.get().max(size)); + } +} + +impl SpanQueue { + /// `mi_span_queue_push`: also marks the slice free (`block_size = 0`). + fn push(&self, slice: &Slice) { + debug_assert!(slice.prev.get().is_null() && slice.next.get().is_null()); + slice.prev.set(ptr::null_mut()); + slice.next.set(self.first.get()); + self.first.set(ptr::from_ref(slice).cast_mut()); + // SAFETY: queue nodes are live slices. + match unsafe { slice.next.get().as_ref() } { + Some(next) => next.prev.set(ptr::from_ref(slice).cast_mut()), + None => self.last.set(ptr::from_ref(slice).cast_mut()), + } + slice.block_size.set(0); // free + } + + /// `mi_span_queue_delete`: also marks the slice used (`block_size = 1`). + fn delete(&self, slice: &Slice) { + debug_assert!(slice.block_size.get() == 0 && slice.slice_count.get() > 0); + // SAFETY: prev/next are live queue nodes. + unsafe { + if let Some(prev) = slice.prev.get().as_ref() { + prev.next.set(slice.next.get()); + } + if ptr::from_ref(slice) == self.first.get() { + self.first.set(slice.next.get()); + } + if let Some(next) = slice.next.get().as_ref() { + next.prev.set(slice.prev.get()); + } + if ptr::from_ref(slice) == self.last.get() { + self.last.set(slice.prev.get()); + } + } + slice.prev.set(ptr::null_mut()); + slice.next.set(ptr::null_mut()); + slice.block_size.set(1); // no longer free + } +} + +// --------------------------------------------------------------------------- +// Span free / coalesce / allocate +// --------------------------------------------------------------------------- + +impl Segment { + /// `mi_segment_span_free`: register `[slice_index, +slice_count)` as a + /// free span (queue it unless huge/abandoned), optionally purging. + fn span_free( + &self, + slice_index: usize, + slice_count: usize, + allow_purge: bool, + tld: &SegmentsTld, + ) { + debug_assert!(slice_index < self.slice_entries.get()); + let queue = (self.kind.get() != SegmentKind::Huge && !self.is_abandoned()) + .then(|| tld.span_queue_for(slice_count)); + let slice_count = slice_count.max(1); + let slice = &self.slices[slice_index]; + slice.slice_count.set(slice_count as u32); + slice.slice_offset.set(0); + if slice_count > 1 { + let last_index = (slice_index + slice_count - 1).min(self.slice_entries.get()); + let last = &self.slices[last_index]; + last.slice_count.set(0); + last.slice_offset + .set((size_of::() * (last_index - slice_index)) as u32); + last.block_size.set(0); + } + if allow_purge { + self.schedule_purge(self.slice_start(slice), slice_count * SEGMENT_SLICE_SIZE); + } + match queue { + Some(sq) => sq.push(slice), + None => slice.block_size.set(0), // mark free anyway + } + } + + /// `mi_segment_span_remove_from_queue` + fn span_remove_from_queue(&self, slice: &Slice, tld: &SegmentsTld) { + debug_assert!(self.kind.get() != SegmentKind::Huge); + tld.span_queue_for(slice.slice_count.get() as usize) + .delete(slice); + } + + /// `mi_segment_span_free_coalesce`: free a span, merging with free + /// neighbors. Returns the (possibly extended) span head. + pub fn span_free_coalesce<'a>(&'a self, slice: &'a Slice, tld: &SegmentsTld) -> &'a Slice { + debug_assert!(slice.slice_count.get() > 0 && slice.slice_offset.get() == 0); + if self.kind.get() == SegmentKind::Huge { + slice.block_size.set(0); + return slice; + } + let is_abandoned = self.is_abandoned(); + let mut slice_count = slice.slice_count.get() as usize; + let idx = self.slice_index(slice); + let mut head_idx = idx; + let next_idx = idx + slice_count; + if next_idx < self.slice_entries.get() { + let next = &self.slices[next_idx]; + if next.block_size.get() == 0 { + slice_count += next.slice_count.get() as usize; + if !is_abandoned { + self.span_remove_from_queue(next, tld); + } + } + } + if idx > 0 { + let prev = self.slices[idx - 1].slice_first(); + if prev.block_size.get() == 0 { + slice_count += prev.slice_count.get() as usize; + let prev_idx = self.slice_index(prev); + slice.slice_count.set(0); + slice + .slice_offset + .set((size_of::() * (idx - prev_idx)) as u32); + if !is_abandoned { + self.span_remove_from_queue(prev, tld); + } + head_idx = prev_idx; + } + } + self.span_free(head_idx, slice_count, true, tld); + &self.slices[head_idx] + } + + /// `mi_segment_span_allocate`: turn a free span into a page. + /// Returns `None` if committing failed. + fn span_allocate(&self, slice_index: usize, slice_count: usize) -> Option<&Page> { + let slice = &self.slices[slice_index]; + debug_assert!(slice.block_size.get() <= 1); + let (start, _) = self.page_start_from_slice(slice, 0); + if !self.ensure_committed(start, slice_count * SEGMENT_SLICE_SIZE) { + return None; + } + slice.slice_offset.set(0); + slice.slice_count.set(slice_count as u32); + let bsize = slice_count * SEGMENT_SLICE_SIZE; + slice.block_size.set(bsize); + + // Back-offsets for interior pointer lookup: first + // MAX_SLICE_OFFSET_COUNT entries and the last one (for coalescing). + let mut extra = (slice_count - 1).min(MAX_SLICE_OFFSET_COUNT); + if slice_index + extra >= self.slice_entries.get() { + extra = self.slice_entries.get() - slice_index - 1; + } + for i in 1..=extra { + let s = &self.slices[slice_index + i]; + s.slice_offset.set((size_of::() * i) as u32); + s.slice_count.set(0); + s.block_size.set(1); + } + let last_index = (slice_index + slice_count - 1).min(self.slice_entries.get()); + if last_index > slice_index { + let last = &self.slices[last_index]; + last.slice_offset + .set((size_of::() * (last_index - slice_index)) as u32); + last.slice_count.set(0); + last.block_size.set(1); + } + + let page = slice; + page.is_committed.set(true); + page.is_zero_init.set(self.free_is_zero.get()); + page.is_huge.set(self.kind.get() == SegmentKind::Huge); + self.used.set(self.used.get() + 1); + Some(page) + } + + /// `mi_segment_slice_split`: split a used span, freeing the tail. + fn slice_split(&self, slice: &Slice, slice_count: usize, tld: &SegmentsTld) { + debug_assert!(slice.slice_count.get() as usize >= slice_count); + debug_assert!(slice.block_size.get() > 0); + if slice.slice_count.get() as usize <= slice_count { + return; + } + debug_assert!(self.kind.get() != SegmentKind::Huge); + let next_index = self.slice_index(slice) + slice_count; + let next_count = slice.slice_count.get() as usize - slice_count; + self.span_free(next_index, next_count, false, tld); + slice.slice_count.set(slice_count as u32); + } +} + +/// `mi_segments_page_find_and_allocate`: best-fit search in the span queues. +fn page_find_and_allocate<'a>( + slice_count: usize, + req_arena: crate::arena::ArenaId, + tld: &SegmentsTld, +) -> Option<&'a Page> { + debug_assert!(slice_count * SEGMENT_SLICE_SIZE <= LARGE_OBJ_SIZE_MAX); + let start_bin = slice_bin(slice_count); + let slice_count = slice_count.max(1); + for sq in &tld.spans[start_bin..] { + let mut p = sq.first.get(); + // SAFETY: queue nodes are live slices in live segments. + while let Some(slice) = unsafe { p.as_ref() } { + if slice.slice_count.get() as usize >= slice_count + && slice.segment().memid_suitable(req_arena) + { + let segment = slice.segment(); + sq.delete(slice); + if slice.slice_count.get() as usize > slice_count { + segment.slice_split(slice, slice_count, tld); + } + debug_assert!( + slice.slice_count.get() as usize == slice_count && slice.block_size.get() > 0 + ); + let index = segment.slice_index(slice); + return match segment.span_allocate(index, slice_count) { + Some(page) => Some(page), + None => { + // commit failed; restore the slice + segment.span_free_coalesce(slice, tld); + None + } + }; + } + p = slice.next.get(); + } + } + None +} + +// --------------------------------------------------------------------------- +// Segment allocation +// --------------------------------------------------------------------------- + +/// `mi_segment_calculate_slices` +fn calculate_slices(mut required: usize) -> (usize, usize) { + let page_size = os::page_size(); + let mut isize_ = align_up(size_of::(), page_size); + let mut guardsize = 0; + if cfg!(feature = "secure") { + // a protected page between the segment info and the page data, + // and one at the end of the segment + guardsize = page_size; + if required > 0 { + required = align_up(required, SEGMENT_SLICE_SIZE) + page_size; + } + } + isize_ = align_up(isize_ + guardsize, SEGMENT_SLICE_SIZE); + let info_slices = isize_ / SEGMENT_SLICE_SIZE; + let segment_size = if required == 0 { + SEGMENT_SIZE + } else { + align_up(required + isize_ + guardsize, SEGMENT_SLICE_SIZE) + }; + (segment_size / SEGMENT_SLICE_SIZE, info_slices) +} + +/// `mi_segment_os_alloc` (direct OS allocation; arenas come later). +fn segment_os_alloc<'a>( + page_alignment: usize, + mut segment_slices: usize, + info_slices: usize, + commit: bool, + required: usize, + req_arena: crate::arena::ArenaId, + tld: &SegmentsTld, +) -> Option<(&'a Segment, usize)> { + let mut alignment = SEGMENT_ALIGN; + let mut align_offset = 0; + if page_alignment > 0 { + debug_assert!(page_alignment >= SEGMENT_ALIGN); + alignment = page_alignment; + let info_size = info_slices * SEGMENT_SLICE_SIZE; + align_offset = align_up(info_size, SEGMENT_ALIGN); + let extra = align_offset - info_size; + (segment_slices, _) = calculate_slices(required + extra); + } + let segment_size = segment_slices * SEGMENT_SLICE_SIZE; + // Prefer the arenas for standard-aligned allocations (syscall-free + // reuse, batched purging); fall back to the OS segment cache / mmap. + // Heaps bound to a specific arena always try it, rounding odd huge + // sizes up to whole blocks. + if align_offset == 0 + && alignment == SEGMENT_ALIGN + && (segment_size.is_multiple_of(crate::arena::ARENA_BLOCK_SIZE) + || req_arena != crate::arena::ARENA_ID_NONE) + && let Some((p, is_zero, aref)) = crate::arena::alloc( + segment_size.div_ceil(crate::arena::ARENA_BLOCK_SIZE), + req_arena, + ) + { + let mem = OsMem { + base: p, + size: segment_size, + align: alignment, + is_committed: true, + is_zero, + }; + if !is_zero { + // SAFETY: the header is zeroed below, before the reference is used. + unsafe { ptr::write_bytes(p, 0, size_of::()) }; + } + // SAFETY: aligned, committed, header zeroed/zero. + let segment: &Segment = unsafe { &*p.cast() }; + segment.commit_mask.set_full(); + segment.memid.set(MemId { + mem, + source: MemSource::Arena(aref), + is_pinned: false, + initially_committed: true, + initially_zero: is_zero, + }); + segment.allow_decommit.set(true); + segment.allow_purge.set(options::purge_delay() >= 0); + segment.segment_size.set(segment_size); + segment.purge_expire.set(0); + segment.purge_mask.clear_all(); + segment.free_is_zero.set(is_zero); + tld.track_size(segment_size as isize); + return Some((segment, segment_slices)); + } + // A heap bound to a specific arena must never fall back to the OS + // (mirrors `_mi_arena_alloc_aligned`, which fails with ENOMEM when an + // arena id is requested): pages of an OS segment are not + // `memid_suitable` for such a heap, so `segments_page_alloc` would loop + // forever allocating segments it can never use — and a huge page would + // silently escape the reservation. + if req_arena != crate::arena::ARENA_ID_NONE { + return None; + } + // Allocate so that `base + align_offset` is `alignment`-aligned. + let (p, mem) = if align_offset == 0 { + if segment_size == SEGMENT_SIZE + && alignment == SEGMENT_ALIGN + && let Some(cached) = os::cache_pop() + { + cached + } else { + OsMem::alloc_aligned(segment_size, alignment, commit)? + } + } else { + let (p, mem) = OsMem::alloc_aligned(segment_size + alignment, alignment, commit)?; + // SAFETY: offset stays inside the over-allocated mapping. + (unsafe { p.add(alignment - align_offset) }, mem) + }; + let commit_needed = (info_slices * SEGMENT_SLICE_SIZE).div_ceil(COMMIT_SIZE); + if !mem.is_committed { + // SAFETY: committing the info prefix of our fresh mapping. + unsafe { os::commit(p, commit_needed * COMMIT_SIZE) }; + } + // Zero the whole header + slices metadata before creating the &Segment: + // reused regions hold stale or partially-uninitialized bytes, `Cell::set` + // reads the old value, and writing through the raw pointer *after* + // taking the reference would invalidate it. The all-zero pattern is + // valid for every field. (Fresh OS memory is already zero.) + if !mem.is_zero { + // SAFETY: the info area is committed and covers the Segment struct. + unsafe { ptr::write_bytes(p, 0, size_of::()) }; + } + // SAFETY: header initialized just above; slice metadata is initialized + // by `segment_alloc` before use. + let segment: &Segment = unsafe { &*p.cast() }; + if mem.is_committed { + segment.commit_mask.set_full(); + } else { + segment + .commit_mask + .copy_from(&CommitMask::create(0, commit_needed)); + } + segment.memid.set(MemId { + mem, + source: MemSource::Os, + is_pinned: false, + initially_committed: mem.is_committed, + initially_zero: mem.is_zero, + }); + segment.allow_decommit.set(true); + segment.allow_purge.set(options::purge_delay() >= 0); + segment.segment_size.set(segment_size); + segment.purge_expire.set(0); + segment.purge_mask.clear_all(); + segment.free_is_zero.set(mem.is_zero); + tld.track_size(segment_size as isize); + Some((segment, segment_slices)) +} + +/// `mi_segment_alloc`: allocate a fresh segment; if `required > 0` it is a +/// huge segment and the huge page is returned too. +fn segment_alloc<'a>( + required: usize, + page_alignment: usize, + req_arena: crate::arena::ArenaId, + tld: &SegmentsTld, +) -> Option<(&'a Segment, Option<&'a Page>)> { + let (segment_slices, info_slices) = calculate_slices(required); + let eager_delay = crate::init::current_thread_count() > 1 + && tld.peak_count.get() < options::eager_commit_delay() as usize; + let eager = !eager_delay && options::eager_commit() == 1; + let commit = eager || required > 0; + + let (segment, segment_slices) = segment_os_alloc( + page_alignment, + segment_slices, + info_slices, + commit, + required, + req_arena, + tld, + )?; + + let mut slice_entries = segment_slices.min(SLICES_PER_SEGMENT); + let mut guard_slices = 0; + if cfg!(feature = "secure") { + // Guard page between the segment metadata and the page data, and + // one at the very end of the segment; a trailing slice is + // sacrificed so the guard never overlaps usable block area. + let ps = os::page_size(); + let info_size = info_slices * SEGMENT_SLICE_SIZE; + let seg_size = segment_slices * SEGMENT_SLICE_SIZE; // true size (huge > 32MiB) + // SAFETY: both pages are inside our mapping; the end page is + // committed first if the segment was reserved lazily. + unsafe { + os::protect(segment.as_ptr().add(info_size - ps), ps); + let end = segment.as_ptr().add(seg_size - ps); + if !commit { + os::commit(end, ps); + } + os::protect(end, ps); + } + if slice_entries == segment_slices { + slice_entries -= 1; // don't use the last slice + } + guard_slices = 1; + } + segment.segment_slices.set(segment_slices); + segment.segment_info_slices.set(info_slices); + segment + .thread_id + .store(crate::init::thread_id(), Ordering::Release); + segment + .cookie + .set(segment.as_ptr().addr() ^ 0xa5a5_5a5a_1234_5678); + segment.slice_entries.set(slice_entries); + segment.kind.set(if required == 0 { + SegmentKind::Normal + } else { + SegmentKind::Huge + }); + + // Reserve the first slices for the segment info. + let page0 = segment.span_allocate(0, info_slices)?; + debug_assert!(ptr::eq(page0, &segment.slices[0])); + segment.used.set(0); // info slices don't count toward usage + + if segment.kind.get() == SegmentKind::Normal { + segment.span_free(info_slices, slice_entries - info_slices, false, tld); + Some((segment, None)) + } else { + let huge = + segment.span_allocate(info_slices, segment_slices - info_slices - guard_slices)?; + Some((segment, Some(huge))) + } +} + +/// `mi_segment_os_free` +fn segment_os_free(segment: &Segment, tld: &SegmentsTld) { + segment.thread_id.store(0, Ordering::Relaxed); + tld.track_size(-(segment.size() as isize)); + if segment.was_reclaimed.get() { + tld.reclaim_count.set(tld.reclaim_count.get() - 1); + segment.was_reclaimed.set(false); + } + if cfg!(feature = "secure") { + // Remove the guard pages so the memory can be recycled. + let ps = os::page_size(); + // SAFETY: the same ranges protected in segment_alloc. + unsafe { + os::unprotect(segment.as_ptr().add(segment.info_size() - ps), ps); + os::unprotect(segment.as_ptr().add(segment.size() - ps), ps); + } + } + let memid = segment.memid.get(); + match memid.source { + MemSource::Arena(aref) => { + // Returning blocks only flips bitmap bits — no dealloc happens + // while references into the segment are still on the stack. + crate::arena::free(aref, segment.commit_mask.is_full()); + } + MemSource::Os => { + // Cache standard segments for syscall-free reuse (the mapping + // stays committed: stat-neutral until eviction); otherwise + // defer the unmap since references into the segment may still + // be on the call stack. + let mem = memid.mem; + let standard = mem.size == SEGMENT_SIZE && mem.base.addr() & SEGMENT_MASK == 0; + if !os::cache_push(mem, standard) { + os::defer_free(mem); + } + } + } +} + +/// `mi_segment_free` +fn segment_free(segment: &Segment, tld: &SegmentsTld) { + debug_assert!(segment.used.get() == 0); + if segment.dont_free.get() { + return; + } + // Remove remaining free spans from the queues. + let mut p = segment.slices.as_ptr(); + while p < segment.slices_end() { + // SAFETY: span heads partition the slices array. + let slice = unsafe { &*p }; + debug_assert!(slice.slice_count.get() > 0 && slice.slice_offset.get() == 0); + if slice.block_size.get() == 0 && segment.kind.get() != SegmentKind::Huge { + segment.span_remove_from_queue(slice, tld); + } + // SAFETY: stays within slices array bounds. + p = unsafe { p.add(slice.slice_count.get() as usize) }; + } + segment_os_free(segment, tld); +} + +// --------------------------------------------------------------------------- +// Page free / abandonment +// --------------------------------------------------------------------------- + +impl Segment { + /// `mi_segment_page_clear`: release a fully-free page back to the segment. + fn page_clear<'a>(&'a self, page: &'a Page, tld: &SegmentsTld) -> &'a Slice { + debug_assert!(page.all_free()); + debug_assert!(self.used.get() > 0); + + // Zero the page metadata (but keep segment fields and the heap tag). + page.is_zero_init.set(false); + let heap_tag = page.heap_tag.get(); + page.capacity.set(0); + page.reserved.set(0); + page.set_in_full(false); + page.set_has_aligned(false); + page.free_is_zero.set(false); + page.retire_expire.set(0); + page.free.set(ptr::null_mut()); + page.local_free.set(ptr::null_mut()); + page.used.set(0); + page.block_size_shift.set(0); + page.block_size.set(1); + page.block_recip.set(0); + page.heap_tag.set(heap_tag); + page.page_start.set(ptr::null_mut()); + page.xthread_free.store(0, Ordering::Relaxed); + page.xheap.store(0, Ordering::Relaxed); + page.next.set(ptr::null_mut()); + page.prev.set(ptr::null_mut()); + + crate::stats::STATS.pages.decrease(1); + let slice = self.span_free_coalesce(page, tld); + self.used.set(self.used.get() - 1); + self.free_is_zero.set(false); + slice + } + + /// Debug validator (port of `mi_segment_is_valid`), asserting the + /// Lean-proven span invariants (`Spans.lean`) on a live segment: + /// span heads partition the slice array exactly (`norm_total`), all + /// spans are non-empty (`norm_wf`), no two adjacent free spans exist + /// (`norm_coalesced`), and back-offsets let interior pointers and + /// coalescing find span heads. + #[cfg(debug_assertions)] + pub fn assert_valid(&self) { + if self.kind.get() != SegmentKind::Normal { + return; // huge layouts intentionally exceed `slice_entries` + } + let entries = self.slice_entries.get(); + let mut idx = 0; + let mut total = 0; + let mut prev_free = false; + while idx < entries { + let head = &self.slices[idx]; + let count = head.slice_count.get() as usize; + debug_assert!(count > 0, "empty span at {idx}"); + debug_assert!( + head.slice_offset.get() == 0, + "span head with offset at {idx}" + ); + let is_free = head.block_size.get() == 0; + debug_assert!( + !(prev_free && is_free), + "adjacent free spans at {idx} (coalescing incomplete)" + ); + prev_free = is_free; + // back-offsets: interior entries point at the head + let last = (idx + count - 1).min(entries - 1); + if is_free { + if last > idx { + let l = &self.slices[last]; + debug_assert!( + l.slice_offset.get() as usize == size_of::() * (last - idx), + "free-span tail back-offset wrong at {last}" + ); + } + } else { + let extra = (count - 1) + .min(MAX_SLICE_OFFSET_COUNT) + .min(entries - idx - 1); + for i in 1..=extra { + let e = &self.slices[idx + i]; + debug_assert!( + e.slice_offset.get() as usize == size_of::() * i + && e.slice_count.get() == 0, + "used-span back-offset wrong at {}", + idx + i + ); + } + } + total += count; + idx += count; + } + debug_assert!( + total == entries, + "span partition broken: covers {total} of {entries} slices" + ); + } + + /// `_mi_segment_page_free` + pub fn page_free(&self, page: &Page, _force: bool, tld: &SegmentsTld) { + #[cfg(debug_assertions)] + self.assert_valid(); + self.page_clear(page, tld); + if self.used.get() == 0 { + segment_free(self, tld); + } else if self.used.get() == self.abandoned.get() { + self.abandon(tld); + } else { + self.try_purge(false); + } + } + + /// `mi_segment_abandon`: all pages are abandoned; move the segment to + /// the global abandoned list. + fn abandon(&self, tld: &SegmentsTld) { + debug_assert!(self.used.get() == self.abandoned.get() && self.used.get() > 0); + debug_assert!(self.abandoned_visits.get() == 0); + + // Remove the free spans from the span queues (they stay marked free). + let mut p = self.slices.as_ptr(); + while p < self.slices_end() { + // SAFETY: span heads partition the slices array. + let slice = unsafe { &*p }; + if slice.block_size.get() == 0 && self.kind.get() != SegmentKind::Huge { + self.span_remove_from_queue(slice, tld); + slice.block_size.set(0); // keep it free + } + // SAFETY: stays in bounds. + p = unsafe { p.add(slice.slice_count.get() as usize) }; + } + + self.try_purge(options::abandoned_page_purge() == 1); + + crate::stats::STATS + .segments_abandoned + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); + tld.track_size(-(self.size() as isize)); + self.thread_id.store(0, Ordering::Release); + self.abandoned_visits.set(1); + if self.was_reclaimed.get() { + tld.reclaim_count.set(tld.reclaim_count.get() - 1); + self.was_reclaimed.set(false); + } + abandoned::mark(self); + } + + /// `_mi_segment_page_abandon` + pub fn page_abandon(&self, page: &Page, tld: &SegmentsTld) { + debug_assert!(page.heap().is_null()); + self.abandoned.set(self.abandoned.get() + 1); + debug_assert!(self.abandoned.get() <= self.used.get()); + if self.used.get() == self.abandoned.get() { + self.abandon(tld); + } + let _ = page; + } +} + +/// Global registry of abandoned segments (stand-in for the arena bitmaps + +/// subprocess OS-list of mimalloc; all our segments are OS-allocated). +pub mod abandoned { + use super::Segment; + use core::ptr; + use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + struct List { + lock: AtomicBool, + first: core::cell::Cell<*mut Segment>, + last: core::cell::Cell<*mut Segment>, + } + // SAFETY: all access happens under `lock`. + unsafe impl Sync for List {} + + pub static RECLAIM_TRIES: AtomicUsize = AtomicUsize::new(0); + pub static RECLAIM_HITS: AtomicUsize = AtomicUsize::new(0); + + static LIST: List = List { + lock: AtomicBool::new(false), + first: core::cell::Cell::new(ptr::null_mut()), + last: core::cell::Cell::new(ptr::null_mut()), + }; + static COUNT: AtomicUsize = AtomicUsize::new(0); + + pub fn count() -> usize { + COUNT.load(Ordering::Relaxed) + } + + #[cfg(not(windows))] + pub(crate) fn lock_for_fork() { + crate::sync::spin_acquire(&LIST.lock); + } + #[cfg(not(windows))] + pub(crate) fn unlock_for_fork() { + crate::sync::spin_release(&LIST.lock); + } + + fn locked(f: impl FnOnce() -> R) -> R { + crate::sync::spin_locked(&LIST.lock, f) + } + + /// `_mi_arena_segment_mark_abandoned` (OS-list flavor): push at the end. + pub fn mark(segment: &Segment) { + locked(|| { + link_last(segment); + }); + COUNT.fetch_add(1, Ordering::Relaxed); + } + + /// Append `segment` at the tail. Lock must be held. + fn link_last(segment: &Segment) { + segment.abandoned_os_next.set(ptr::null_mut()); + segment.abandoned_os_prev.set(LIST.last.get()); + segment.abandoned_os_listed.set(true); + // SAFETY: list nodes are live abandoned segments. + match unsafe { LIST.last.get().as_ref() } { + Some(last) => last + .abandoned_os_next + .set(ptr::from_ref(segment).cast_mut()), + None => LIST.first.set(ptr::from_ref(segment).cast_mut()), + } + LIST.last.set(ptr::from_ref(segment).cast_mut()); + } + + fn unlink(segment: &Segment) { + // SAFETY: list nodes are live abandoned segments. + unsafe { + match segment.abandoned_os_prev.get().as_ref() { + Some(prev) => prev.abandoned_os_next.set(segment.abandoned_os_next.get()), + None => LIST.first.set(segment.abandoned_os_next.get()), + } + match segment.abandoned_os_next.get().as_ref() { + Some(next) => next.abandoned_os_prev.set(segment.abandoned_os_prev.get()), + None => LIST.last.set(segment.abandoned_os_prev.get()), + } + } + segment.abandoned_os_next.set(ptr::null_mut()); + segment.abandoned_os_prev.set(ptr::null_mut()); + segment.abandoned_os_listed.set(false); + COUNT.fetch_sub(1, Ordering::Relaxed); + } + + /// `_mi_arena_segment_clear_abandoned`: atomically un-abandon a specific + /// segment. Returns false if some other thread claimed it first. + pub fn clear(segment: &Segment) -> bool { + locked(|| { + let on_list = segment.abandoned_os_listed.get(); + if on_list { + unlink(segment); + } + on_list + }) + } + + /// Pop the first abandoned segment, if any. + pub fn pop<'a>() -> Option<&'a Segment> { + if COUNT.load(Ordering::Relaxed) == 0 { + return None; + } + locked(|| { + // SAFETY: list nodes are live abandoned segments. + let segment = unsafe { LIST.first.get().as_ref() }?; + unlink(segment); + Some(segment) + }) + } + + /// Detach up to `n` segments from the head in a single lock acquisition. + /// Returns a chain linked through `abandoned_os_next`; every node is off + /// the list (`clear` on it returns false) and exclusively ours. + pub fn take<'a>(n: usize) -> Option<&'a Segment> { + if n == 0 || COUNT.load(Ordering::Relaxed) == 0 { + return None; + } + let (head, taken) = locked(|| { + let head = LIST.first.get(); + // SAFETY: list nodes are live abandoned segments (lock held). + let Some(mut tail) = (unsafe { head.as_ref() }) else { + return (head, 0); + }; + let mut taken = 1usize; + tail.abandoned_os_prev.set(ptr::null_mut()); + tail.abandoned_os_listed.set(false); + while taken < n + // SAFETY: list nodes are live abandoned segments (lock held). + && let Some(next) = unsafe { tail.abandoned_os_next.get().as_ref() } + { + tail = next; + tail.abandoned_os_prev.set(ptr::null_mut()); + tail.abandoned_os_listed.set(false); + taken += 1; + } + let rest = tail.abandoned_os_next.get(); + tail.abandoned_os_next.set(ptr::null_mut()); + LIST.first.set(rest); + // SAFETY: as above. + match unsafe { rest.as_ref() } { + Some(r) => r.abandoned_os_prev.set(ptr::null_mut()), + None => LIST.last.set(ptr::null_mut()), + } + (head, taken) + }); + if taken > 0 { + COUNT.fetch_sub(taken, Ordering::Relaxed); + } + // SAFETY: chain head is a live abandoned segment owned by us. + unsafe { head.as_ref() } + } + + /// Re-abandon a chain of `count` segments (linked via `abandoned_os_next`, + /// as built by the caller) in a single lock acquisition. + pub fn splice(head: *mut Segment, count: usize) { + if head.is_null() { + return; + } + locked(|| { + let mut p = head; + // SAFETY: chain nodes are live abandoned segments owned by the + // caller until spliced (lock held). + while let Some(segment) = unsafe { p.as_ref() } { + let next = segment.abandoned_os_next.get(); + link_last(segment); + p = next; + } + }); + COUNT.fetch_add(count, Ordering::Relaxed); + } +} + +// --------------------------------------------------------------------------- +// Reclaim +// --------------------------------------------------------------------------- + +/// Iterate span heads, skipping the initial info span. +fn slices_start_iterate(segment: &Segment) -> (*const Slice, *const Slice) { + let slice = segment.slices.as_ptr(); + // SAFETY: first span is the info span; its count is in bounds. + let start = unsafe { slice.add((*slice).slice_count.get() as usize) }; + (start, segment.slices_end()) +} + +/// `mi_segment_check_free`: collect frees; report if a usable page/span exists. +fn segment_check_free( + segment: &Segment, + slices_needed: usize, + block_size: usize, + tld: &SegmentsTld, +) -> bool { + debug_assert!(segment.is_abandoned()); + let mut has_page = false; + let (mut p, end) = slices_start_iterate(segment); + while p < end { + // SAFETY: span heads partition the slices array. + let mut slice = unsafe { &*p }; + if slice.slice_is_used() { + let page = slice; + crate::page_ops::page_free_collect(page, false); + if page.all_free() { + segment.abandoned.set(segment.abandoned.get() - 1); + slice = segment.page_clear(page, tld); + if slice.slice_count.get() as usize >= slices_needed { + has_page = true; + } + } else if page.block_size() == block_size && page.has_any_available() { + has_page = true; + } + } else if slice.slice_count.get() as usize >= slices_needed { + has_page = true; + } + let next = segment.slice_index(slice) + slice.slice_count.get() as usize; + // SAFETY: span heads stay within the slices array. + p = unsafe { segment.slices.as_ptr().add(next) }; + } + has_page +} + +/// `mi_segment_reclaim`: take ownership of an abandoned segment. +/// Returns `None` if the segment was freed entirely. +fn segment_reclaim<'a>( + segment: &'a Segment, + heap: &Heap, + requested_block_size: usize, + mut right_page_reclaimed: Option<&mut bool>, + tld: &SegmentsTld, +) -> Option<&'a Segment> { + if let Some(flag) = right_page_reclaimed.as_deref_mut() { + *flag = false; + } + segment + .thread_id + .store(crate::init::thread_id(), Ordering::Release); + segment.abandoned_visits.set(0); + segment.was_reclaimed.set(true); + tld.reclaim_count.set(tld.reclaim_count.get() + 1); + tld.track_size(segment.size() as isize); + debug_assert!(segment.next.get().is_null()); + + let (mut p, end) = slices_start_iterate(segment); + while p < end { + // SAFETY: span heads partition the slices array. + let mut slice = unsafe { &*p }; + if slice.slice_is_used() { + let page = slice; + debug_assert!(page.is_committed.get()); + debug_assert!(page.heap().is_null()); + segment.abandoned.set(segment.abandoned.get() - 1); + page.set_heap(ptr::from_ref(heap).cast_mut()); + crate::page_ops::page_use_delayed_free( + page, + crate::page::Delayed::UseDelayedFree, + true, + ); + crate::page_ops::page_free_collect(page, false); + if page.all_free() { + slice = segment.page_clear(page, tld); + } else { + crate::page_ops::page_reclaim(heap, page); + if requested_block_size == page.block_size() + && page.has_any_available() + && let Some(flag) = right_page_reclaimed.as_deref_mut() + { + *flag = true; + } + } + } else { + slice = segment.span_free_coalesce(slice, tld); + } + let next = segment.slice_index(slice) + slice.slice_count.get() as usize; + // SAFETY: span heads stay within the slices array. + p = unsafe { segment.slices.as_ptr().add(next) }; + } + + debug_assert!(segment.abandoned.get() == 0); + if segment.used.get() == 0 { + segment_free(segment, tld); + None + } else { + Some(segment) + } +} + +/// `_mi_segment_attempt_reclaim`: called on a cross-thread free into an +/// abandoned segment. +pub fn attempt_reclaim(heap: &Heap, segment: &Segment) -> bool { + if !segment.is_abandoned() || !segment.memid_suitable(heap.arena_id.get()) { + return false; + } + let target = options::target_segments_per_thread(); + let tld = heap.segments_tld(); + if target > 0 && target as usize <= tld.count.get() { + return false; + } + if abandoned::clear(segment) { + segment_reclaim(segment, heap, 0, None, tld).is_some() + } else { + false + } +} + +/// `_mi_abandoned_reclaim_all` +pub fn reclaim_all(heap: &Heap, tld: &SegmentsTld) { + while let Some(segment) = abandoned::pop() { + segment_reclaim(segment, heap, 0, None, tld); + } +} + +/// `mi_segment_try_reclaim` +fn try_reclaim<'a>( + heap: &Heap, + needed_slices: usize, + block_size: usize, + reclaimed: &mut bool, + tld: &SegmentsTld, +) -> Option<&'a Segment> { + *reclaimed = false; + let mut max_tries = { + // ~10% of the abandoned count, clamped to [8, 1024]. + let perc = options::max_segment_reclaim().clamp(0, 100) as usize; + let total = abandoned::count(); + if perc == 0 || total == 0 { + return None; + } + let relative = (total * perc) / 100; + let tries = relative.clamp(1, 1024); + if tries < 8 && total > 8 { 8 } else { tries } + }; + while max_tries > 0 { + max_tries -= 1; + let target = options::target_segments_per_thread().clamp(0, 1024) as usize; + if target != 0 && tld.count.get() >= target { + break; + } + let Some(segment) = abandoned::pop() else { + break; + }; + abandoned::RECLAIM_TRIES.fetch_add(1, Ordering::Relaxed); + segment + .abandoned_visits + .set(segment.abandoned_visits.get() + 1); + let has_page = segment_check_free(segment, needed_slices, block_size, tld); + if segment.used.get() == 0 { + segment_reclaim(segment, heap, 0, None, tld); + } else if has_page { + abandoned::RECLAIM_HITS.fetch_add(1, Ordering::Relaxed); + return segment_reclaim(segment, heap, block_size, Some(reclaimed), tld); + } else if segment.abandoned_visits.get() > 3 { + segment_reclaim(segment, heap, 0, None, tld); + } else { + max_tries += 1; // not a real try + segment.try_purge(false); + abandoned::mark(segment); + } + } + None +} + +/// `_mi_abandoned_collect` +pub fn abandoned_collect(heap: &Heap, force: bool, tld: &SegmentsTld) { + // One pass over the segments abandoned at entry (the C version's arena + // cursor visits each at most once); re-pushed segments are not revisited. + // The whole batch is detached in one lock acquisition and survivors are + // re-abandoned in one more, instead of locking twice per segment. + let max_tries = if force { + abandoned::count() + } else { + abandoned::count().min(1024) + }; + let mut p = abandoned::take(max_tries); + let mut keep_head: *mut Segment = ptr::null_mut(); + let mut keep_tail: *mut Segment = ptr::null_mut(); + let mut keep_count = 0usize; + while let Some(segment) = p { + // SAFETY: the chain was detached by `take`; read the link before the + // segment is potentially freed below. + p = unsafe { segment.abandoned_os_next.get().as_ref() }; + segment.abandoned_os_next.set(ptr::null_mut()); + segment_check_free(segment, 0, 0, tld); + if segment.used.get() == 0 { + segment_reclaim(segment, heap, 0, None, tld); + } else { + segment.try_purge(force); + let sp = ptr::from_ref(segment).cast_mut(); + if keep_tail.is_null() { + keep_head = sp; + } else { + // SAFETY: keep_tail is a live segment owned by this chain. + unsafe { (*keep_tail).abandoned_os_next.set(sp) }; + } + keep_tail = sp; + keep_count += 1; + } + } + abandoned::splice(keep_head, keep_count); +} + +// --------------------------------------------------------------------------- +// Page allocation entry points +// --------------------------------------------------------------------------- + +/// `mi_segment_force_abandon`: abandon every used page of `segment` so the +/// segment becomes reclaimable by other threads. The pages may be freed in +/// the process; `dont_free` keeps the segment metadata valid throughout. +fn segment_force_abandon(segment: &Segment) { + debug_assert!(!segment.is_abandoned()); + segment.dont_free.set(true); + let (start, end) = slices_start_iterate(segment); + let mut p = start; + while p < end { + // SAFETY: span heads partition the slices array. + let mut slice = unsafe { &*p }; + if slice.slice_is_used() { + let page = slice.slice_first(); + crate::page_ops::page_free_collect(page, false); + if segment.used.get() == segment.abandoned.get() + 1 { + // last in-use page: after abandoning it the segment itself + // is abandoned and must not be touched again. + segment.dont_free.set(false); + crate::page_ops::page_force_abandon(page); + return; + } + crate::page_ops::page_force_abandon(page); + // the page may have been freed and coalesced backward; restart + // from the (possibly merged) span head before advancing. + slice = slice.slice_first(); + } + let next = segment.slice_index(slice) + slice.slice_count.get() as usize; + // SAFETY: span heads stay within the slices array. + p = unsafe { segment.slices.as_ptr().add(next) }; + } + segment.dont_free.set(false); +} + +/// `mi_segments_try_abandon`: when over the per-thread segment target, +/// abandon full-bin segments to increase cross-thread reuse. +fn segments_try_abandon(heap: &Heap, tld: &SegmentsTld) { + let target = options::target_segments_per_thread(); + if target <= 0 || tld.count.get() < target as usize { + return; + } + let target = target as usize; + let min_target = if target > 4 { target * 3 / 4 } else { target }; + // Only full-bin pages hold candidate segments (mirrors C's heuristic of + // not maintaining a per-thread segment list). + for _ in 0..64 { + if tld.count.get() < min_target { + break; + } + let mut p = heap.pages[crate::constants::BIN_FULL].first.get(); + // SAFETY: queue nodes are live pages of this heap. + while let Some(page) = unsafe { p.as_ref() } { + if page.block_size() <= LARGE_OBJ_SIZE_MAX { + break; + } + p = page.next.get(); + } + // SAFETY: queue nodes are live pages of this heap. + let Some(page) = (unsafe { p.as_ref() }) else { + break; + }; + segment_force_abandon(page.segment()); + } +} + +/// `mi_segment_reclaim_or_alloc` +fn reclaim_or_alloc<'a>( + heap: &Heap, + needed_slices: usize, + block_size: usize, + tld: &SegmentsTld, +) -> Option<&'a Segment> { + debug_assert!(block_size <= LARGE_OBJ_SIZE_MAX); + // Shed excess segments first so other threads can reclaim them. + segments_try_abandon(heap, tld); + let mut reclaimed = false; + let segment = try_reclaim(heap, needed_slices, block_size, &mut reclaimed, tld); + if reclaimed { + // The right page is already in the heap's queue; report "no segment" + // so the caller retries through the page queues. + return None; + } + if let Some(segment) = segment { + return Some(segment); + } + segment_alloc(0, 0, heap.arena_id.get(), tld).map(|(s, _)| s) +} + +/// `mi_segments_page_alloc` +fn segments_page_alloc<'a>( + heap: &Heap, + required: usize, + block_size: usize, + tld: &SegmentsTld, +) -> Option<&'a Page> { + let page_size = align_up( + required, + if required > MEDIUM_PAGE_SIZE { + MEDIUM_PAGE_SIZE + } else { + SEGMENT_SLICE_SIZE + }, + ); + let slices_needed = page_size / SEGMENT_SLICE_SIZE; + loop { + if let Some(page) = page_find_and_allocate(slices_needed, heap.arena_id.get(), tld) { + page.segment().try_purge(false); + return Some(page); + } + reclaim_or_alloc(heap, slices_needed, block_size, tld)?; + } +} + +/// `mi_segment_huge_page_alloc` +fn huge_page_alloc<'a>( + size: usize, + page_alignment: usize, + req_arena: crate::arena::ArenaId, + tld: &SegmentsTld, +) -> Option<&'a Page> { + let (segment, page) = segment_alloc(size, page_alignment, req_arena, tld)?; + let page = page?; + debug_assert!(segment.used.get() == 1 && page.block_size() >= size); + let (start, psize) = segment.page_start(page); + page.set_block_size(psize); + debug_assert!(page.is_huge.get()); + + // Decommit the unused prefix of very large alignments. + if page_alignment > 0 && segment.allow_decommit.get() { + let aligned = align_up(start.addr(), page_alignment); + debug_assert!(psize - (aligned - start.addr()) >= size); + // SAFETY: range is inside the page, past the block header word. + unsafe { + let decommit_start = start.add(size_of::()); // keep the free-list word + let len = aligned - decommit_start.addr(); + if len > 0 { + os::reset(decommit_start, len); + } + } + } + Some(page) +} + +/// `_mi_segment_huge_page_reset`: a huge block was freed on another thread; +/// release its physical memory while the owner still holds it. +pub fn huge_page_reset(segment: &Segment, page: &Page, block: *mut crate::page::Block) { + debug_assert!(segment.kind.get() == SegmentKind::Huge); + debug_assert!(page.used.get() == 1 && page.free.get().is_null()); + if segment.allow_decommit.get() { + let csize = page.block_size() - size_of::(); + // SAFETY: the huge block spans the page; skip the first word. + unsafe { + os::reset(block.cast::().add(size_of::()), csize); + } + } +} + +/// `_mi_segment_page_alloc`: the entry point from `page.c`. +pub fn segment_page_alloc<'a>( + heap: &Heap, + block_size: usize, + page_alignment: usize, + tld: &SegmentsTld, +) -> Option<&'a Page> { + let page = if page_alignment > BLOCK_ALIGNMENT_MAX { + debug_assert!(is_power_of_two(page_alignment)); + let page_alignment = page_alignment.max(SEGMENT_SIZE); + huge_page_alloc(block_size, page_alignment, heap.arena_id.get(), tld)? + } else if block_size <= SMALL_OBJ_SIZE_MAX { + segments_page_alloc(heap, block_size, block_size, tld)? + } else if block_size <= MEDIUM_OBJ_SIZE_MAX { + segments_page_alloc(heap, MEDIUM_PAGE_SIZE, block_size, tld)? + } else if block_size <= LARGE_OBJ_SIZE_MAX { + segments_page_alloc(heap, block_size, block_size, tld)? + } else { + huge_page_alloc(block_size, page_alignment, heap.arena_id.get(), tld)? + }; + Some(page) +} diff --git a/vendor/rimalloc/src/stats.rs b/vendor/rimalloc/src/stats.rs new file mode 100644 index 00000000..11664758 --- /dev/null +++ b/vendor/rimalloc/src/stats.rs @@ -0,0 +1,149 @@ +//! Statistics (port of `stats.c`, condensed). Segment/page-level events are +//! always counted (they are rare); per-allocation counting is gated behind +//! the `stats` cargo feature, mirroring C's `MI_STAT` levels. + +use core::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; + +/// `mi_stat_count_t`: current/peak/total. +#[derive(Default)] +pub struct StatCount { + pub current: AtomicI64, + pub peak: AtomicI64, + pub total: AtomicI64, +} + +impl StatCount { + pub const fn new() -> StatCount { + StatCount { + current: AtomicI64::new(0), + peak: AtomicI64::new(0), + total: AtomicI64::new(0), + } + } + pub fn increase(&self, amount: usize) { + let cur = self.current.fetch_add(amount as i64, Ordering::Relaxed) + amount as i64; + self.peak.fetch_max(cur, Ordering::Relaxed); + self.total.fetch_add(amount as i64, Ordering::Relaxed); + } + pub fn decrease(&self, amount: usize) { + self.current.fetch_sub(amount as i64, Ordering::Relaxed); + } + fn get(&self) -> (i64, i64, i64) { + ( + self.current.load(Ordering::Relaxed), + self.peak.load(Ordering::Relaxed), + self.total.load(Ordering::Relaxed), + ) + } +} + +/// Process-wide statistics (`_mi_stats_main`). +pub struct Stats { + pub segments: StatCount, + pub pages: StatCount, + pub committed: StatCount, + pub reserved: StatCount, + pub threads: StatCount, + pub malloc_normal: StatCount, + pub malloc_huge: StatCount, + #[allow(dead_code)] // read by stats-feature builds / external consumers + pub malloc_normal_count: AtomicUsize, + #[allow(dead_code)] + pub malloc_huge_count: AtomicUsize, + pub pages_extended: AtomicUsize, + pub pages_retire: AtomicUsize, + pub segments_abandoned: AtomicUsize, + pub segments_reclaimed: AtomicUsize, +} + +pub static STATS: Stats = Stats { + segments: StatCount::new(), + pages: StatCount::new(), + committed: StatCount::new(), + reserved: StatCount::new(), + threads: StatCount::new(), + malloc_normal: StatCount::new(), + malloc_huge: StatCount::new(), + malloc_normal_count: AtomicUsize::new(0), + malloc_huge_count: AtomicUsize::new(0), + pages_extended: AtomicUsize::new(0), + pages_retire: AtomicUsize::new(0), + segments_abandoned: AtomicUsize::new(0), + segments_reclaimed: AtomicUsize::new(0), +}; + +/// Per-allocation accounting; compiled out unless the `stats` feature is on +/// (like release-mode C with `MI_STAT=0`). +#[inline(always)] +pub fn stat_malloc(size: usize, huge: bool) { + #[cfg(feature = "stats")] + { + if huge { + STATS.malloc_huge.increase(size); + STATS.malloc_huge_count.fetch_add(1, Ordering::Relaxed); + } else { + STATS.malloc_normal.increase(size); + STATS.malloc_normal_count.fetch_add(1, Ordering::Relaxed); + } + } + #[cfg(not(feature = "stats"))] + { + let _ = (size, huge); + } +} + +fn fmt_bytes(n: i64) -> String { + let n = n.max(0) as f64; + if n >= 1e9 { + format!("{:.1} GiB", n / (1u64 << 30) as f64) + } else if n >= 1e6 { + format!("{:.1} MiB", n / (1u64 << 20) as f64) + } else if n >= 1e3 { + format!("{:.1} KiB", n / 1024.0) + } else { + format!("{n} B") + } +} + +/// `mi_stats_print`: write a summary to stderr (or any writer). +pub fn print(out: &mut dyn std::io::Write) -> std::io::Result<()> { + let rows: &[(&str, &StatCount, bool)] = &[ + ("reserved", &STATS.reserved, true), + ("committed", &STATS.committed, true), + ("segments", &STATS.segments, false), + ("pages", &STATS.pages, false), + ("threads", &STATS.threads, false), + ("malloc normal", &STATS.malloc_normal, true), + ("malloc huge", &STATS.malloc_huge, true), + ]; + writeln!( + out, + "{:<14} {:>12} {:>12} {:>12}", + "heap stats:", "current", "peak", "total" + )?; + for (name, stat, bytes) in rows { + let (cur, peak, total) = stat.get(); + if *bytes { + writeln!( + out, + "{:<14} {:>12} {:>12} {:>12}", + name, + fmt_bytes(cur), + fmt_bytes(peak), + fmt_bytes(total) + )?; + } else { + writeln!(out, "{name:<14} {cur:>12} {peak:>12} {total:>12}")?; + } + } + writeln!( + out, + "{:<14} extended: {}, retired: {}, abandoned: {}, reclaimed: {}", + "pages ops:", + STATS.pages_extended.load(Ordering::Relaxed), + STATS.pages_retire.load(Ordering::Relaxed), + STATS.segments_abandoned.load(Ordering::Relaxed), + STATS.segments_reclaimed.load(Ordering::Relaxed), + )?; + Ok(()) +} diff --git a/vendor/rimalloc/src/sync.rs b/vendor/rimalloc/src/sync.rs new file mode 100644 index 00000000..0a48e607 --- /dev/null +++ b/vendor/rimalloc/src/sync.rs @@ -0,0 +1,30 @@ +//! Tiny spinlock used by the global caches and lists. Contention is rare +//! (segment-level events only) and critical sections are a few pointer +//! writes, so spinning beats an OS mutex here. + +use core::hint::spin_loop; +use core::sync::atomic::{AtomicBool, Ordering}; + +#[inline] +pub(crate) fn spin_acquire(lock: &AtomicBool) { + while lock + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + spin_loop(); + } +} + +#[inline] +pub(crate) fn spin_release(lock: &AtomicBool) { + lock.store(false, Ordering::Release); +} + +/// Run `f` under `lock`. +#[inline] +pub(crate) fn spin_locked(lock: &AtomicBool, f: impl FnOnce() -> R) -> R { + spin_acquire(lock); + let r = f(); + spin_release(lock); + r +} diff --git a/vendor/rimalloc/src/visit.rs b/vendor/rimalloc/src/visit.rs new file mode 100644 index 00000000..48042b2e --- /dev/null +++ b/vendor/rimalloc/src/visit.rs @@ -0,0 +1,139 @@ +//! Heap walking (port of `heap.c`'s `mi_heap_visit_blocks`): iterate every +//! page area of a heap and optionally every live block inside it. Live +//! blocks are found by building a bitmap of the free list — `local_free` +//! and `thread_free` are collected first so `used` is exact. + +use crate::constants::*; +use crate::heap::Heap; +use crate::page::Page; +use crate::page_ops::page_free_collect; + +/// `mi_heap_area_t`: a snapshot of one page's block area. +#[derive(Clone, Copy, Debug)] +pub struct HeapArea { + pub blocks: *mut u8, // start of the area + pub reserved: usize, // bytes reserved + pub committed: usize, // bytes committed + pub used: usize, // blocks in use + pub block_size: usize, // usable block size + pub full_block_size: usize, // size including internal padding + pub heap_tag: u8, +} + +impl HeapArea { + fn init(page: &Page) -> HeapArea { + let bsize = page.block_size(); + HeapArea { + blocks: page.start(), + reserved: page.reserved.get() as usize * bsize, + committed: page.capacity.get() as usize * bsize, + used: page.used.get() as usize, + block_size: page.usable_block_size(), + full_block_size: bsize, + heap_tag: page.heap_tag.get(), + } + } +} + +const MAX_BLOCKS: usize = SMALL_PAGE_SIZE / PTR_SIZE; // 8192 +const MAP_WORDS: usize = MAX_BLOCKS / usize::BITS as usize; + +/// `_mi_heap_area_visit_blocks`: call `visitor(block, usable_size)` for +/// every live block of `page`; stops early on `false`. +fn area_visit_blocks(page: &Page, visitor: &mut impl FnMut(*mut u8, usize) -> bool) -> bool { + if page.used.get() == 0 { + return true; + } + let pstart = page.start(); + let bsize = page.block_size(); + let ubsize = page.usable_block_size(); + + // optimize: page with one block + if page.capacity.get() == 1 { + return visitor(pstart, ubsize); + } + + // optimize: full page + if page.used.get() == page.capacity.get() { + let mut block = pstart; + for _ in 0..page.capacity.get() { + if !visitor(block, ubsize) { + return false; + } + // SAFETY: blocks stay within the page area. + block = unsafe { block.add(bsize) }; + } + return true; + } + + // bitmap of free blocks + let capacity = page.capacity.get() as usize; + debug_assert!(capacity <= MAX_BLOCKS); + let mut free_map = [0usize; MAP_WORDS]; + let bmapsize = capacity.div_ceil(usize::BITS as usize); + if !capacity.is_multiple_of(usize::BITS as usize) { + // mark left-over bits at the end as free + free_map[bmapsize - 1] = usize::MAX << (capacity % usize::BITS as usize); + } + let mut p = page.free.get(); + while !p.is_null() { + let offset = p.addr() - pstart.addr(); + debug_assert!(offset.is_multiple_of(bsize)); + let blockidx = offset / bsize; + free_map[blockidx / usize::BITS as usize] |= 1 << (blockidx % usize::BITS as usize); + // SAFETY: free-list nodes are live blocks in this page. + p = unsafe { page.block_next(p) }; + } + + // walk all blocks, skipping the free ones + let mut block = pstart; + for &word in &free_map[..bmapsize] { + if word == 0 { + // every block in this word is in use + for _ in 0..usize::BITS { + if !visitor(block, ubsize) { + return false; + } + // SAFETY: in-bounds while bits remain. + block = unsafe { block.add(bsize) }; + } + } else { + let mut m = !word; + while m != 0 { + let bitidx = m.trailing_zeros() as usize; + // SAFETY: bit indices map within the page area. + if !visitor(unsafe { block.add(bitidx * bsize) }, ubsize) { + return false; + } + m &= m - 1; + } + // SAFETY: word-stride within (or one past) the page area. + block = unsafe { block.add(bsize * usize::BITS as usize) }; + } + } + true +} + +/// `mi_heap_visit_blocks`: visit every area of `heap`, and every live block +/// within each area when `visit_blocks` is true. The visitor receives the +/// area and, for block visits, `Some((block, usable_size))`; returning +/// `false` stops the walk. Returns `false` if the walk was stopped. +pub fn heap_visit_blocks( + heap: &Heap, + visit_blocks: bool, + visitor: &mut impl FnMut(&HeapArea, Option<(*mut u8, usize)>) -> bool, +) -> bool { + crate::heap_ops::heap_visit_pages(heap, |_, _, page| { + // collect so the used count is exact + page_free_collect(page, true); + let area = HeapArea::init(page); + if !visitor(&area, None) { + return false; + } + if visit_blocks { + area_visit_blocks(page, &mut |block, size| visitor(&area, Some((block, size)))) + } else { + true + } + }) +} diff --git a/vendor/rimalloc/tests/api.rs b/vendor/rimalloc/tests/api.rs new file mode 100644 index 00000000..b04005d3 --- /dev/null +++ b/vendor/rimalloc/tests/api.rs @@ -0,0 +1,412 @@ +//! Faithful port of mimalloc `test/test-api.c` (C++-only STL-allocator and +//! `realpath` cases are exercised through Rust equivalents or skipped). + +use rimalloc as mi; + +const SMALL_SIZE_MAX: usize = 128 * size_of::(); +const MAX_ALIGN_SIZE: usize = 16; +const BLOCK_ALIGNMENT_MAX: usize = 32 * 1024 * 1024 / 2; // MI_SEGMENT_SIZE/2 +const MIB: usize = 1024 * 1024; + +fn mem_is_zero(p: *const u8, size: usize) -> bool { + if p.is_null() { + return false; + } + (0..size).all(|i| unsafe { *p.add(i) } == 0) +} + +#[test] +fn malloc_aligned9a() { + // test large alignments + let p = mi::zalloc_aligned(1024 * 1024, 2); + mi::free(p); + let p = mi::zalloc_aligned(1024 * 1024, 2); + mi::free(p); +} + +#[test] +fn malloc_zero() { + let p = mi::malloc(0); + assert!(!p.is_null()); + mi::free(p); +} + +#[test] +fn malloc_nomem1() { + assert!(mi::malloc(isize::MAX as usize + 1).is_null()); +} + +#[test] +fn malloc_null() { + mi::free(std::ptr::null_mut()); +} + +#[test] +fn calloc_overflow() { + assert!(mi::calloc(&raw const COOKIE as usize, usize::MAX / 1000).is_null()); +} +static COOKIE: u8 = 0; + +#[test] +fn calloc0() { + let p = mi::calloc(0, 1000); + assert!(mi::usable_size(p) <= 16); + mi::free(p); +} + +#[test] +fn malloc_large() { + // see PR #544 + let p = mi::malloc(67108872); + mi::free(p); +} + +#[test] +fn posix_memalign1() { + let mut p: *mut u8 = std::ptr::null_mut(); + let err = mi::posix_memalign(&mut p, size_of::(), 32); + assert!(err == 0 && p.addr() % size_of::() == 0); + mi::free(p); +} + +#[test] +fn posix_memalign_no_align() { + let mut p: *mut u8 = std::ptr::dangling_mut(); + let err = mi::posix_memalign(&mut p, 3, 32); + assert!(err == mi::EINVAL && p == std::ptr::dangling_mut()); +} + +#[test] +fn posix_memalign_zero() { + let mut p: *mut u8 = std::ptr::null_mut(); + let err = mi::posix_memalign(&mut p, size_of::(), 0); + mi::free(p); + assert_eq!(err, 0); +} + +#[test] +fn posix_memalign_nopow2() { + let mut p: *mut u8 = std::ptr::dangling_mut(); + let err = mi::posix_memalign(&mut p, 3 * size_of::(), 32); + assert!(err == mi::EINVAL && p == std::ptr::dangling_mut()); +} + +#[test] +fn posix_memalign_nomem() { + let mut p: *mut u8 = std::ptr::dangling_mut(); + let err = mi::posix_memalign(&mut p, size_of::(), usize::MAX); + assert!(err == mi::ENOMEM && p == std::ptr::dangling_mut()); +} + +#[test] +fn malloc_aligned1() { + let p = mi::malloc_aligned(32, 32); + assert!(!p.is_null() && p.addr() % 32 == 0); + mi::free(p); +} + +#[test] +fn malloc_aligned2() { + let p = mi::malloc_aligned(48, 32); + assert!(!p.is_null() && p.addr() % 32 == 0); + mi::free(p); +} + +#[test] +fn malloc_aligned3() { + let p1 = mi::malloc_aligned(48, 32); + assert!(!p1.is_null() && p1.addr() % 32 == 0); + let p2 = mi::malloc_aligned(48, 32); + assert!(!p2.is_null() && p2.addr() % 32 == 0); + mi::free(p2); + mi::free(p1); +} + +#[test] +fn malloc_aligned4() { + for _ in 0..8 { + let p = mi::malloc_aligned(8, 16); + assert!(!p.is_null() && p.addr() % 16 == 0); + mi::free(p); + } +} + +#[test] +fn malloc_aligned5() { + let p = mi::malloc_aligned(4097, 4096); + let usable = mi::usable_size(p); + assert!(usable >= 4097 && usable < 16000, "usable: {usable}"); + mi::free(p); +} + +#[test] +fn malloc_aligned7() { + let p = mi::malloc_aligned(1024, BLOCK_ALIGNMENT_MAX); + assert_eq!(p.addr() % BLOCK_ALIGNMENT_MAX, 0); + mi::free(p); +} + +#[test] +fn malloc_aligned8() { + for i in 0..5 { + let n = 1usize << i; + let p = mi::malloc_aligned(1024, n * BLOCK_ALIGNMENT_MAX); + assert_eq!(p.addr() % (n * BLOCK_ALIGNMENT_MAX), 0, "n={n}"); + mi::free(p); + } +} + +#[test] +fn malloc_aligned9() { + // test large alignments + let sizes: [usize; 8] = [ + 8, + 512, + 1024 * 1024, + BLOCK_ALIGNMENT_MAX, + BLOCK_ALIGNMENT_MAX + 1, + 2 * BLOCK_ALIGNMENT_MAX, + 8 * BLOCK_ALIGNMENT_MAX, + 0, + ]; + let step = if cfg!(miri) { 5 } else { 1 }; + for i in (0..28).step_by(step) { + let align = 1usize << i; + let mut p = [std::ptr::null_mut(); 8]; + for (j, &size) in sizes.iter().enumerate() { + p[j] = mi::zalloc_aligned(size, align); + assert_eq!(p[j].addr() % align, 0, "align={align} size={size}"); + } + for q in p { + mi::free(q); + } + } +} + +#[test] +fn malloc_aligned10() { + let mut p = [std::ptr::null_mut(); 11]; + let mut align = 1; + for j in 0..=10 { + p[j] = mi::malloc_aligned(43 + align, align); + assert_eq!(p[j].addr() % align, 0, "align={align}"); + align *= 2; + } + for q in p.into_iter().rev() { + mi::free(q); + } +} + +#[test] +fn malloc_aligned11() { + let heap = mi::heap_new().unwrap(); + let p = mi::heap_malloc_aligned(heap, 33554426, 8); + assert!(unsafe { mi::heap_contains_block(heap, p) }); + mi::heap_destroy(heap); +} + +#[test] +fn malloc_aligned12() { + let p = mi::malloc_aligned(0x100, 0x100); + assert_eq!(p.addr() % 0x100, 0); // #602 + mi::free(p); +} + +#[test] +fn malloc_aligned13() { + // Under Miri sample the size domain (the interpreter is ~1000x slower); + // natively the full domain is covered. + let step = if cfg!(miri) { 41 } else { 1 }; + let mut size = 1usize; + while size <= SMALL_SIZE_MAX * 2 { + let mut align = 1usize; + while align <= size { + let mut p = [std::ptr::null_mut(); 10]; + for slot in &mut p { + *slot = mi::malloc_aligned(size, align); + assert!( + !slot.is_null() && slot.addr() % align == 0, + "size={size} align={align}" + ); + } + for q in p { + mi::free(q); + } + align *= 2; + } + size += step; + } +} + +#[test] +fn malloc_aligned_at1() { + let p = mi::malloc_aligned_at(48, 32, 0); + assert!(!p.is_null() && p.addr() % 32 == 0); + mi::free(p); +} + +#[test] +fn malloc_aligned_at2() { + let p = mi::malloc_aligned_at(50, 32, 8); + assert!(!p.is_null() && (p.addr() + 8) % 32 == 0); + mi::free(p); +} + +#[test] +fn memalign1() { + for _ in 0..8 { + let p = mi::memalign(16, 8); + assert!(!p.is_null() && p.addr() % 16 == 0); + mi::free(p); + } +} + +#[test] +fn zalloc_aligned_small1() { + let zalloc_size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc_aligned(zalloc_size, MAX_ALIGN_SIZE * 2); + assert!(mem_is_zero(p, zalloc_size)); + mi::free(p); +} + +#[test] +fn rezalloc_aligned_small1() { + let mut zalloc_size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc_aligned(zalloc_size, MAX_ALIGN_SIZE * 2); + assert!(mem_is_zero(p, zalloc_size)); + zalloc_size *= 3; + let p = mi::rezalloc_aligned(p, zalloc_size, MAX_ALIGN_SIZE * 2); + assert!(mem_is_zero(p, zalloc_size)); + mi::free(p); +} + +#[test] +fn realloc_null() { + let p = mi::realloc(std::ptr::null_mut(), 4); + assert!(!p.is_null()); + mi::free(p); +} + +#[test] +fn realloc_null_sizezero() { + // "If ptr is NULL, the behavior is the same as calling malloc(new_size)." + let p = mi::realloc(std::ptr::null_mut(), 0); + assert!(!p.is_null()); + mi::free(p); +} + +#[test] +fn realloc_sizezero() { + let p = mi::malloc(4); + let q = mi::realloc(p, 0); + assert!(!q.is_null()); + mi::free(q); +} + +#[test] +fn reallocarray_null_sizezero() { + let p = mi::reallocarray(std::ptr::null_mut(), 0, 16); // issue #574 + assert!(!p.is_null()); + mi::free(p); +} + +#[test] +fn umalloc1() { + let limit = if cfg!(miri) { MIB } else { 32 * MIB }; + let mut size = 1usize; + while size <= limit { + let mut bsize = 0; + let p = mi::umalloc(size, &mut bsize); + assert!(bsize >= size); + let (mut pre, mut post) = (0, 0); + let p = mi::urealloc(p, size + 1024, &mut pre, &mut post); + assert_eq!(pre, bsize); + assert!(post >= size + 1024); + let mut fsize = 0; + mi::ufree(p, &mut fsize); + assert_eq!(fsize, post); + size *= 2; + } +} + +#[test] +fn heap_destroy_test() { + // test_heap1 + let heap = mi::heap_new().unwrap(); + let p1 = mi::heap_malloc(heap, size_of::()).cast::(); + let p2 = mi::heap_malloc(heap, size_of::()).cast::(); + unsafe { + *p1 = 43; + *p2 = 43; + } + mi::heap_destroy(heap); +} + +#[test] +fn heap_delete_test() { + // test_heap2 + let heap = mi::heap_new().unwrap(); + let p1 = mi::heap_malloc(heap, size_of::()).cast::(); + let p2 = mi::heap_malloc(heap, size_of::()).cast::(); + mi::heap_delete(heap); + unsafe { *p1 = 42 }; + mi::free(p1.cast()); + mi::free(p2.cast()); +} + +#[test] +fn rust_allocator_vec() { + // stand-in for the C++ STL allocator tests + let mut sizes = vec![]; + for i in 0..64usize { + let p = mi::malloc(i * 17 + 1); + sizes.push((p, i * 17 + 1)); + } + for (p, _) in sizes { + mi::free(p); + } +} + +#[test] +fn heap_visit_blocks_walk() { + // mirror of test-stress's HEAP_WALK: sum visited block sizes and count + // blocks; all live allocations must be visited exactly once. + let heap = mi::heap_new().unwrap(); + let sizes = [1usize, 8, 16, 100, 700, 8000, 70_000, 200_000]; + let ptrs: Vec<*mut u8> = sizes.iter().map(|&s| mi::heap_malloc(heap, s)).collect(); + // free one so a partially-used page is walked via the bitmap path + mi::free(ptrs[1]); + + let mut count = 0usize; + let mut total = 0usize; + let complete = mi::heap_visit_blocks(heap, true, &mut |area, block| { + assert!(area.committed <= area.reserved); + if let Some((p, size)) = block { + assert!(!p.is_null() && size > 0); + count += 1; + total += size; + } + true + }); + assert!(complete); + assert_eq!(count, sizes.len() - 1); + assert!(total >= sizes.iter().sum::() - sizes[1]); + + // early stop works + let mut seen = 0; + let complete = mi::heap_visit_blocks(heap, true, &mut |_, block| { + if block.is_some() { + seen += 1; + return seen < 2; + } + true + }); + assert!(!complete && seen == 2); + + for (i, p) in ptrs.iter().enumerate() { + if i != 1 { + mi::free(*p); + } + } + mi::heap_destroy(heap); +} diff --git a/vendor/rimalloc/tests/api_alignment.rs b/vendor/rimalloc/tests/api_alignment.rs new file mode 100644 index 00000000..9b8ae5a7 --- /dev/null +++ b/vendor/rimalloc/tests/api_alignment.rs @@ -0,0 +1,60 @@ +//! Regression tests for GlobalAlloc alignment and realloc edge cases found +//! by adversarial differential testing against C mimalloc. + +use std::alloc::{GlobalAlloc, Layout}; + +/// The 8-byte size class is only 8-aligned, so a `{size <= 8, align: 16}` +/// layout must route through the aligned allocator (C's +/// `mi_malloc_is_naturally_aligned`). +#[test] +fn global_alloc_honors_align16_for_tiny_sizes() { + let a = rimalloc::Rimalloc; + for size in 1..=16usize { + for &align in &[8usize, 16, 32, 64] { + let layout = Layout::from_size_align(size, align).unwrap(); + // Miri interprets every allocation; a few iterations exercise the + // same routing the native run hammers 256x. + let iters = if cfg!(miri) { 4 } else { 256 }; + for _ in 0..iters { + let p = unsafe { a.alloc(layout) }; + assert!(!p.is_null()); + assert_eq!(p.addr() % align, 0, "size {size} align {align}: {:p}", p); + let z = unsafe { a.alloc_zeroed(layout) }; + assert_eq!(z.addr() % align, 0); + assert_eq!(unsafe { *z }, 0); + unsafe { + a.dealloc(p, layout); + a.dealloc(z, layout); + } + } + } + } +} + +/// `realloc` preserving a large alignment must still honor it, and a big +/// shrink must release the oversized block rather than retain it forever. +#[test] +fn realloc_aligned_shrinks_and_keeps_alignment() { + let p = rimalloc::malloc_aligned(8 << 20, 256); + assert_eq!(p.addr() % 256, 0); + let q = rimalloc::realloc_aligned(p, 64, 256); + assert_eq!(q.addr() % 256, 0); + assert!( + rimalloc::usable_size(q) < 4096, + "aligned shrink retained the oversized block: {}", + rimalloc::usable_size(q) + ); + rimalloc::free(q); +} + +/// `realloc(p, 0)` returns a non-null minimal block whose first byte is +/// zeroed (C issue #725), and frees the original. +#[test] +fn realloc_to_zero_is_zeroed() { + let p = rimalloc::malloc(128); + unsafe { p.write_bytes(0xAB, 128) }; + let q = rimalloc::realloc(p, 0); + assert!(!q.is_null()); + assert_eq!(unsafe { *q }, 0); + rimalloc::free(q); +} diff --git a/vendor/rimalloc/tests/arena_purge.rs b/vendor/rimalloc/tests/arena_purge.rs new file mode 100644 index 00000000..44f4019b --- /dev/null +++ b/vendor/rimalloc/tests/arena_purge.rs @@ -0,0 +1,41 @@ +//! A freed span inside a *live* arena-backed segment must be returned to the +//! OS in-life (C purges every segment's freed spans via mi_segment_span_free, +//! regardless of backing — only whole-segment teardown is left to the arena). +//! Skipping this for arena-backed segments leaks resident memory until the +//! whole segment is freed (a ~2x RSS regression in decommit mode). +//! +//! Deterministic signal: in decommit mode an in-life purge calls os::decommit, +//! which remaps PROT_NONE via mmap (syscall counter index 0). A pin keeps the +//! segment alive so the free cannot trigger whole-segment teardown instead. + +use rimalloc::options::{self, Option}; + +#[test] +fn arena_segment_frees_span_in_life() { + // Decommit mode, immediate purge (no delay), so the free itself purges. + options::set(Option::PurgeDecommits, 1); + options::set(Option::PurgeDelay, 0); + + // Pin a (32MiB, arena-backed) segment so it stays live across the free. + let pin = rimalloc::malloc(1024); + assert!(!pin.is_null()); + + // A large span (< LARGE_OBJ_SIZE_MAX = 16MiB) lands as a large page in the + // same segment. Touch it so the pages are resident, then free it. + let span = 12 << 20; + let big = rimalloc::malloc(span); + assert!(!big.is_null()); + unsafe { big.write_bytes(0xA5, span) }; + + let decommits_before = rimalloc::os_syscall_counts()[0]; + rimalloc::free(big); + let decommits_after = rimalloc::os_syscall_counts()[0]; + + assert!( + decommits_after > decommits_before, + "freed span of a live arena-backed segment was not decommitted in-life \ + (RSS regression): decommit syscalls {decommits_before} -> {decommits_after}" + ); + + rimalloc::free(pin); +} diff --git a/vendor/rimalloc/tests/committed_stat.rs b/vendor/rimalloc/tests/committed_stat.rs new file mode 100644 index 00000000..59d8c442 --- /dev/null +++ b/vendor/rimalloc/tests/committed_stat.rs @@ -0,0 +1,124 @@ +//! The `committed` stat tracks committed bytes at whole-segment +//! granularity (see `SegmentsTld::track_size`): it must rise when segments +//! are created, fall when they are released, and never go negative — under +//! both reset-mode and decommit-mode purges. +//! +//! The whole-segment counter is balanced *by construction*: every increase +//! (segment enters) is paired with an equal decrease (segment leaves) at the +//! same granularity, so a churn cycle can never drive it negative and it +//! returns to its pre-churn baseline once everything is collected — in either +//! purge mode. (A globally *exact*, sub-segment counter is deliberately not +//! attempted; see the `track_size` doc comment for why it stays coarse.) +#![cfg(feature = "stats")] + +use rimalloc::options::{self, Option}; + +#[test] +fn committed_rises_and_falls_without_underflow() { + let base = rimalloc::stats_committed_current(); + assert!(base >= 0); + + let p = rimalloc::malloc(8 * 1024); + assert!(!p.is_null()); + unsafe { p.write_bytes(7, 8 * 1024) }; + let after_alloc = rimalloc::stats_committed_current(); + assert!(after_alloc > base, "segment commit not counted"); + + rimalloc::free(p); + rimalloc::collect(true); + let after_free = rimalloc::stats_committed_current(); + assert!( + after_free >= 0, + "committed stat went negative: {after_free}" + ); + assert!( + after_free <= after_alloc, + "free/purge did not reduce committed: {after_alloc} -> {after_free}" + ); + + // Aligned (trim-path) mapping and a huge allocation must also balance. + let a = rimalloc::malloc_aligned(1 << 20, 1 << 21); + assert!(!a.is_null()); + rimalloc::free(a); + let h = rimalloc::malloc(40 << 20); + assert!(!h.is_null()); + rimalloc::free(h); + rimalloc::collect(true); + assert!(rimalloc::stats_committed_current() >= 0); +} + +/// Drive a churn cycle that forces many segments (and arena blocks) through +/// alloc/free/purge, in *both* purge modes, asserting the committed counter +/// never underflows mid-churn and lands back at a bounded baseline after a +/// full collect. This is the exact OS<->arena cross-layer purge path the +/// routing change touches; under the old segment-level decommit of arena +/// memory this churn could drive committed negative. +#[test] +fn committed_never_underflows_across_churn_in_both_modes() { + // 0 = reset/madvise mode, 1 = decommit mode. + for &decommit in &[0_i64, 1_i64] { + options::set(Option::PurgeDecommits, decommit); + // Short delays so purges actually fire during the run. + options::set(Option::PurgeDelay, 0); + + let baseline = rimalloc::stats_committed_current(); + assert!( + baseline >= 0, + "committed negative at baseline (mode decommit={decommit}): {baseline}" + ); + + // Several rounds of churn: enough live segments to span multiple + // arena blocks, freed and re-collected each round. + for round in 0..8 { + let mut live = Vec::new(); + // ~24 MiB of 256 KiB blocks => multiple segments / arena blocks. + for i in 0..96 { + let p = rimalloc::malloc(256 * 1024); + assert!(!p.is_null()); + // Touch the first and last page so the range is really faulted. + unsafe { + p.write_bytes((i & 0xff) as u8, 64); + p.add(256 * 1024 - 64).write_bytes(0xab, 64); + } + live.push(p); + let cur = rimalloc::stats_committed_current(); + assert!( + cur >= 0, + "committed negative during alloc (mode={decommit}, round={round}, i={i}): {cur}" + ); + } + for p in live.drain(..) { + rimalloc::free(p); + let cur = rimalloc::stats_committed_current(); + assert!( + cur >= 0, + "committed negative during free (mode={decommit}, round={round}): {cur}" + ); + } + rimalloc::collect(true); + let cur = rimalloc::stats_committed_current(); + assert!( + cur >= 0, + "committed negative after collect (mode={decommit}, round={round}): {cur}" + ); + } + + rimalloc::collect(true); + let after = rimalloc::stats_committed_current(); + assert!( + after >= 0, + "committed negative after churn (mode decommit={decommit}): {after}" + ); + // The coarse counter is balanced by construction: after a full collect + // it must not have grown unboundedly relative to the round's working + // set (a leak would show here). Allow some cached segments to remain. + assert!( + after <= baseline + (64 << 20), + "committed leaked across churn (mode decommit={decommit}): \ + baseline={baseline} after={after}" + ); + } + // Restore defaults for any later test in this binary. + options::set(Option::PurgeDecommits, 1); + options::set(Option::PurgeDelay, 10); +} diff --git a/vendor/rimalloc/tests/coverage_gaps.rs b/vendor/rimalloc/tests/coverage_gaps.rs new file mode 100644 index 00000000..6d1793c2 --- /dev/null +++ b/vendor/rimalloc/tests/coverage_gaps.rs @@ -0,0 +1,680 @@ +//! Targeted tests for the least-covered regions found by cargo-llvm-cov: +//! API wrapper variants and their overflow/OOM arms, huge-alignment +//! branches, `heap_absorb`/`heap_destroy` arms, abandoned-segment +//! reclaim-on-free, the segment cache, arena purging, heap visiting, and +//! the stats/options surfaces. + +#![cfg(not(miri))] // exercised natively; the slow paths here add nothing under Miri + +use rimalloc as mi; + +const MIB: usize = 1024 * 1024; +const SMALL_SIZE_MAX: usize = 128 * size_of::(); +const BLOCK_ALIGNMENT_MAX: usize = 16 * MIB; // MI_SEGMENT_SIZE / 2 +const MAX_ALLOC_SIZE: usize = isize::MAX as usize; + +// --------------------------------------------------------------------------- +// API wrapper variants and overflow arms +// --------------------------------------------------------------------------- + +#[test] +fn small_variants() { + let p = mi::malloc_small(64); + assert!(!p.is_null()); + mi::free(p); + let p = mi::zalloc_small(SMALL_SIZE_MAX); + assert!(!p.is_null()); + assert!((0..SMALL_SIZE_MAX).all(|i| unsafe { *p.add(i) } == 0)); + mi::free(p); +} + +#[test] +fn counted_overflow_arms() { + assert!(mi::mallocn(usize::MAX / 8, 16).is_null()); + assert!(mi::reallocn(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); + assert!(mi::recalloc(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); + assert!(mi::calloc_aligned(usize::MAX / 8, 16, 64).is_null()); + assert!(mi::calloc_aligned_at(usize::MAX / 8, 16, 64, 0).is_null()); + assert!(mi::recalloc_aligned(std::ptr::null_mut(), usize::MAX / 8, 16, 64).is_null()); + assert!(mi::reallocarray(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); + + let p = mi::mallocn(4, 32); + assert!(!p.is_null()); + let p = mi::reallocn(p, 8, 32); + assert!(!p.is_null()); + assert!(mi::usable_size(p) >= 256); + let p = mi::reallocarray(p, 16, 32); + assert!(!p.is_null()); + mi::free(p); +} + +#[test] +fn good_size_clamps() { + assert!(mi::good_size(100) >= 100); + assert!(mi::good_size(100 * MIB) >= 100 * MIB); + assert_eq!(mi::good_size(usize::MAX), usize::MAX); // over MAX_ALLOC_SIZE + // Requests above the C v2.3.2 cap (SEGMENT_SLICE_SIZE * (u32::MAX-1), + // issue #877) are returned unchanged, not page-rounded — pins the cap. + let over_cap = 65536 * (u32::MAX as usize - 1) + 1; + assert_eq!(mi::good_size(over_cap), over_cap); +} + +#[test] +fn reallocf_frees_on_failure() { + let p = mi::malloc(100); + assert!(!p.is_null()); + let q = mi::reallocf(p, 200); + assert!(!q.is_null()); + // failure: oversized request returns null AND releases q + assert!(mi::reallocf(q, MAX_ALLOC_SIZE).is_null()); +} + +#[test] +fn realloc_fits_in_place() { + let p = mi::malloc(100); + let usable = mi::usable_size(p); + // shrink within 50% waste: same pointer, no move + assert_eq!(mi::realloc(p, usable - 8), p); + assert_eq!(mi::rezalloc(p, usable - 8), p); + mi::free(p); +} + +#[test] +fn aligned_at_offset_variants() { + let p = mi::zalloc_aligned_at(300, 512, 16); + assert!(!p.is_null() && (p.addr() + 16).is_multiple_of(512)); + assert!((0..300).all(|i| unsafe { *p.add(i) } == 0)); + mi::free(p); + let p = mi::calloc_aligned_at(10, 30, 512, 16); + assert!(!p.is_null() && (p.addr() + 16).is_multiple_of(512)); + assert!((0..300).all(|i| unsafe { *p.add(i) } == 0)); + mi::free(p); +} + +#[test] +fn expand_in_place_only() { + assert!(mi::expand(std::ptr::null_mut(), 16).is_null()); + let p = mi::malloc(100); + let usable = mi::usable_size(p); + assert_eq!(mi::expand(p, usable), p); + assert!(mi::expand(p, usable + 1).is_null()); + mi::free(p); +} + +#[test] +fn recalloc_zeroes_growth() { + let p = mi::recalloc(std::ptr::null_mut(), 4, 64); + assert!(!p.is_null()); + unsafe { p.write_bytes(0xAA, 256) }; + let p = mi::recalloc(p, 64, 64); + assert!(!p.is_null()); + assert!((256..4096).all(|i| unsafe { *p.add(i) } == 0)); + mi::free(p); +} + +// --------------------------------------------------------------------------- +// POSIX-style entry points +// --------------------------------------------------------------------------- + +#[test] +fn posix_memalign_errors_and_success() { + let mut out: *mut u8 = std::ptr::null_mut(); + assert_eq!(mi::posix_memalign(&mut out, 3, 100), mi::EINVAL); + assert_eq!(mi::posix_memalign(&mut out, 24, 100), mi::EINVAL); + assert_eq!( + mi::posix_memalign(&mut out, 64, MAX_ALLOC_SIZE), + mi::ENOMEM + ); + assert_eq!(mi::posix_memalign(&mut out, 128, 100), 0); + assert!(!out.is_null() && out.addr().is_multiple_of(128)); + mi::free(out); +} + +#[test] +fn valloc_is_page_aligned() { + let p = mi::valloc(100); + assert!(!p.is_null()); + assert!(p.addr() % 4096 == 0); + mi::free(p); +} + +// --------------------------------------------------------------------------- +// Aligned-allocation branches +// --------------------------------------------------------------------------- + +#[test] +fn huge_alignment_with_offset_fails() { + // alignment > BLOCK_ALIGNMENT_MAX cannot use an offset + assert!(mi::malloc_aligned_at(1024, 2 * BLOCK_ALIGNMENT_MAX, 64).is_null()); +} + +#[test] +fn huge_alignment_zeroed() { + let align = 2 * BLOCK_ALIGNMENT_MAX; + let p = mi::zalloc_aligned(3 * MIB, align); + assert!(!p.is_null() && p.addr().is_multiple_of(align)); + assert!( + (0..3 * MIB) + .step_by(4097) + .all(|i| unsafe { *p.add(i) } == 0) + ); + mi::free(p); + + // small size still goes through the dedicated huge-segment path + let p = mi::malloc_aligned(32, align); + assert!(!p.is_null() && p.addr().is_multiple_of(align)); + mi::free(p); +} + +#[test] +fn aligned_invalid_arguments() { + assert!(mi::malloc_aligned(64, 0).is_null()); + assert!(mi::malloc_aligned(64, 24).is_null()); + assert!(mi::malloc_aligned(MAX_ALLOC_SIZE + 1, 64).is_null()); + assert!(mi::realloc_aligned(std::ptr::null_mut(), 64, 24).is_null()); +} + +#[test] +fn realloc_aligned_paths() { + // null -> fresh aligned allocation + let p = mi::realloc_aligned(std::ptr::null_mut(), 100, 256); + assert!(!p.is_null() && p.addr().is_multiple_of(256)); + unsafe { p.write_bytes(0x5A, 100) }; + // shrink within the keep window (>= half the usable size) -> same pointer + // (large-shrink reallocation is covered in tests/api_alignment.rs). + let usable = mi::usable_size(p); + assert_eq!(mi::realloc_aligned(p, usable - usable / 4, 256), p); + // grow -> move, contents preserved + let q = mi::realloc_aligned(p, 64 * 1024, 256); + assert!(!q.is_null() && q.addr().is_multiple_of(256)); + assert!((0..100).all(|i| unsafe { *q.add(i) } == 0x5A)); + mi::free(q); + + // rezalloc_aligned zeroes the growth tail + let p = mi::rezalloc_aligned(std::ptr::null_mut(), 64, 128); + unsafe { p.write_bytes(0x77, 64) }; + let q = mi::rezalloc_aligned(p, 8192, 128); + assert!(!q.is_null()); + assert!((64..8192).all(|i| unsafe { *q.add(i) } == 0)); + mi::free(q); + + // recalloc_aligned counted wrapper + let p = mi::recalloc_aligned(std::ptr::null_mut(), 16, 64, 512); + assert!(!p.is_null() && p.addr().is_multiple_of(512)); + mi::free(p); + + // realloc_aligned_at keeps the offset congruence + let p = mi::malloc_aligned_at(100, 1024, 8); + assert!(!p.is_null() && (p.addr() + 8).is_multiple_of(1024)); + let q = mi::realloc_aligned_at(p, 50_000, 1024, 8); + assert!(!q.is_null() && (q.addr() + 8).is_multiple_of(1024)); + mi::free(q); +} + +// --------------------------------------------------------------------------- +// GlobalAlloc surface +// --------------------------------------------------------------------------- + +#[test] +fn global_alloc_trait_paths() { + use core::alloc::{GlobalAlloc, Layout}; + let a = mi::Rimalloc; + unsafe { + let small = Layout::from_size_align(100, 8).unwrap(); + let p = a.alloc(small); + assert!(!p.is_null()); + let p = a.realloc(p, small, 5000); + assert!(!p.is_null()); + a.dealloc(p, Layout::from_size_align(5000, 8).unwrap()); + + let p = a.alloc_zeroed(small); + assert!((0..100).all(|i| *p.add(i) == 0)); + a.dealloc(p, small); + + let big_align = Layout::from_size_align(100, 4096).unwrap(); + let p = a.alloc(big_align); + assert!(p.addr().is_multiple_of(4096)); + let p = a.realloc(p, big_align, 9000); + assert!(p.addr().is_multiple_of(4096)); + a.dealloc(p, Layout::from_size_align(9000, 4096).unwrap()); + + let p = a.alloc_zeroed(big_align); + assert!(p.addr().is_multiple_of(4096) && (0..100).all(|i| *p.add(i) == 0)); + a.dealloc(p, big_align); + } +} + +// --------------------------------------------------------------------------- +// Heap API: defaults, tags, absorb, destroy +// --------------------------------------------------------------------------- + +#[test] +fn heap_default_backing_and_tags() { + let backing = mi::heap_get_backing(); + let heap = mi::heap_new_ex(7, true, 0).expect("heap"); + assert!(heap.by_tag(7).is_some()); + assert!(backing.by_tag(7).is_some()); // reachable via the thread heap list + assert!(heap.by_tag(99).is_none()); + + let old = mi::heap_set_default(heap); + let p = mi::malloc(100); // served by the new default heap + assert!(unsafe { mi::heap_contains_block(heap, p) }); + assert!(!unsafe { mi::heap_contains_block(backing, p) }); + mi::free(p); + mi::heap_set_default(old); + assert_eq!( + mi::heap_get_default() as *const mi::Heap, + old as *const mi::Heap + ); + mi::heap_delete(heap); +} + +#[test] +fn heap_counted_wrappers() { + let heap = mi::heap_new().expect("heap"); + assert!(mi::heap_calloc(heap, usize::MAX / 8, 16).is_null()); + assert!(mi::heap_mallocn(heap, usize::MAX / 8, 16).is_null()); + let p = mi::heap_calloc(heap, 8, 32); + assert!((0..256).all(|i| unsafe { *p.add(i) } == 0)); + let p = mi::heap_realloc(heap, p, 1024); + assert!(!p.is_null()); + let q = mi::heap_mallocn(heap, 4, 64); + assert!(!q.is_null()); + mi::free(p); + mi::free(q); + mi::heap_delete(heap); +} + +#[test] +fn heap_delete_absorbs_live_pages() { + // Fill pages (including full ones) in a fresh heap, then delete it: + // heap_absorb must move every page to the backing heap, after which the + // blocks are still valid and freeable. 4 KiB blocks keep page capacity + // low (16/page) so several pages sit in the full queue at delete time. + let heap = mi::heap_new().expect("heap"); + let mut ptrs = Vec::new(); + for i in 0..4096 { + let p = mi::heap_malloc(heap, 16 + (i % 7) * 32); + assert!(!p.is_null()); + unsafe { p.write_bytes(0xC3, 16) }; + ptrs.push(p); + } + for _ in 0..256 { + let p = mi::heap_malloc(heap, 4096); + assert!(!p.is_null()); + unsafe { p.write_bytes(0xC3, 16) }; + ptrs.push(p); + } + mi::heap_delete(heap); + for p in ptrs { + assert!((0..16).all(|i| unsafe { *p.add(i) } == 0xC3)); + mi::free(p); + } + mi::collect(false); +} + +#[test] +fn heap_destroy_discards_blocks() { + // heap_destroy frees whole pages without freeing individual blocks. + let heap = mi::heap_new().expect("heap"); + for i in 0..512 { + assert!(!mi::heap_malloc(heap, 8 + (i % 11) * 100).is_null()); + } + assert!(!mi::heap_malloc(heap, 20 * MIB).is_null()); // huge page too + mi::heap_destroy(heap); +} + +#[test] +fn heap_destroy_without_destroy_permission_deletes() { + // A heap created with allow_destroy = false routes heap_destroy through + // heap_delete (it may hold reclaimed pages). + let heap = mi::heap_new_ex(0, false, 0).expect("heap"); + let p = mi::heap_malloc(heap, 100); + assert!(!p.is_null()); + mi::heap_destroy(heap); + mi::free(p); // absorbed by the backing heap, still freeable +} + +#[test] +fn heap_delete_from_middle_of_list() { + // The thread heap list is LIFO; deleting the older heap first walks the + // list past the newer one. + let h1 = mi::heap_new().expect("h1"); + let h2 = mi::heap_new().expect("h2"); + assert!(!mi::heap_malloc(h1, 64).is_null()); + assert!(!mi::heap_malloc(h2, 64).is_null()); + mi::heap_delete(h1); + mi::heap_delete(h2); +} + +// --------------------------------------------------------------------------- +// Ownership introspection +// --------------------------------------------------------------------------- + +#[test] +fn ownership_checks() { + let p = mi::malloc(100); + assert!(unsafe { mi::check_owned(p) }); + assert!(!unsafe { mi::check_owned(std::ptr::null()) }); + assert!(!unsafe { mi::check_owned(p.wrapping_add(1)) }); // unaligned + assert!(mi::is_in_heap_region(p)); + assert!(!mi::is_in_heap_region(std::ptr::null())); + let stack = 0u8; + assert!(!mi::is_in_heap_region(&raw const stack)); + let heap = mi::heap_get_default(); + assert!(unsafe { mi::heap_check_owned(heap, p) }); + assert!(!unsafe { mi::heap_contains_block(heap, std::ptr::null()) }); + mi::free(p); +} + +// --------------------------------------------------------------------------- +// Heap visiting (single-block, full-page, and bitmap paths) +// --------------------------------------------------------------------------- + +#[test] +fn visit_blocks_all_page_shapes() { + let heap = mi::heap_new().expect("heap"); + let mut live = Vec::new(); + // partial small page, dense prefix: blocks 0..256 stay live so whole + // bitmap words are all-in-use, the tail is freed + let mut small = Vec::new(); + for _ in 0..300 { + small.push(mi::heap_malloc(heap, 48)); + } + for (i, &p) in small.iter().enumerate() { + if i >= 256 || i % 65 == 64 { + mi::free(p); + } else { + live.push(p); + } + } + // a fully-used page: 8 KiB blocks fill a 64 KiB small page + for _ in 0..8 { + let p = mi::heap_malloc(heap, 8 * 1024 - 64); + assert!(!p.is_null()); + live.push(p); + } + // a one-block (huge) page + let huge = mi::heap_malloc(heap, 20 * MIB); + assert!(!huge.is_null()); + live.push(huge); + + let mut areas = 0usize; + let mut blocks = 0usize; + let complete = mi::heap_visit_blocks(heap, true, &mut |area, block| { + match block { + None => { + assert!(!area.blocks.is_null() && area.full_block_size >= area.block_size); + areas += 1; + } + Some((p, size)) => { + assert!(size > 0 && !p.is_null()); + blocks += 1; + } + } + true + }); + assert!(complete); + assert!(areas >= 2); + assert_eq!(blocks, live.len()); + + // early stop after the first area + let stopped = mi::heap_visit_blocks(heap, true, &mut |_, _| false); + assert!(!stopped); + // area-only walk + assert!(mi::heap_visit_blocks(heap, false, &mut |_, b| { + assert!(b.is_none()); + true + })); + + // a retired-but-retained page (all blocks freed) visits as an empty area + let q: Vec<_> = (0..4).map(|_| mi::heap_malloc(heap, 99)).collect(); + for p in q { + mi::free(p); + } + assert!(mi::heap_visit_blocks(heap, true, &mut |_, _| true)); + + for p in live { + mi::free(p); + } + mi::heap_delete(heap); +} + +// --------------------------------------------------------------------------- +// Cross-thread huge free and abandoned reclaim-on-free +// --------------------------------------------------------------------------- + +#[test] +fn cross_thread_huge_free_resets_memory() { + // Freeing a huge (> LARGE_OBJ_SIZE_MAX) block from a foreign thread + // goes through huge_page_reset before the delayed-free push. + let p = mi::malloc(20 * MIB); + assert!(!p.is_null()); + unsafe { p.write_bytes(0x42, 4096) }; + let addr = p as usize; + std::thread::spawn(move || mi::free(addr as *mut u8)) + .join() + .unwrap(); + mi::collect(true); +} + +#[test] +fn delayed_free_via_full_page() { + // A remote free into a FULL page goes onto the owning heap's delayed + // list (UseDelayedFree); the owner's next collect drains it through + // heap_delayed_free_partial / free_delayed_block. + let mut v = Vec::new(); + for _ in 0..64 { + let p = mi::malloc(4000); // 16 blocks per 64 KiB page + assert!(!p.is_null()); + v.push(p as usize); + } + let first = v[0]; // belongs to a page that is full by now + std::thread::spawn(move || mi::free(first as *mut u8)) + .join() + .unwrap(); + mi::collect(false); + for addr in v.into_iter().skip(1) { + mi::free(addr as *mut u8); + } + mi::collect(false); +} + +#[test] +fn abandoned_reclaim_on_free() { + use rimalloc::options; + let prev = options::get(options::Option::AbandonedReclaimOnFree); + options::set(options::Option::AbandonedReclaimOnFree, 1); + + let ptrs = std::thread::spawn(|| { + let mut v = Vec::new(); + for _ in 0..64 { + let p = mi::malloc(2048); + assert!(!p.is_null()); + unsafe { p.write_bytes(0x66, 2048) }; + v.push(p as usize); + } + v // thread exits with live blocks: its segments are abandoned + }) + .join() + .unwrap(); + + // Warm this thread's allocator: reclaim-on-free only fires on an + // initialized thread. + mi::free(mi::malloc(1)); + let (_, tries_before, _) = mi::abandoned_stats(); + for addr in ptrs { + let p = addr as *mut u8; + assert!((0..2048).all(|i| unsafe { *p.add(i) } == 0x66)); + mi::free(p); // first free reclaims the abandoned segment + } + let (_, tries_after, _) = mi::abandoned_stats(); + assert!(tries_after >= tries_before); + options::set(options::Option::AbandonedReclaimOnFree, prev); + mi::collect(false); +} + +// --------------------------------------------------------------------------- +// Arena multi-block claims and purging; segment cache collection +// --------------------------------------------------------------------------- + +#[test] +fn arena_multi_block_and_purge() { + use rimalloc::options; + // multi-block arena claim: huge segments whose size lands on an exact + // multiple of the 32 MiB arena block are served by the arena bitmap + // (one of these sizes hits 64 MiB exactly, whatever the info-slice + // overhead); the rest go directly to the OS + for slack in 1..=4usize { + let p = mi::malloc(64 * MIB - slack * 64 * 1024); + assert!(!p.is_null()); + unsafe { p.write_bytes(1, 64) }; + mi::free(p); + } + let p = mi::malloc(40 * MIB); + assert!(!p.is_null()); + unsafe { p.write_bytes(1, 64) }; + mi::free(p); + + // schedule and force arena/cache purges + let prev = options::get(options::Option::PurgeDelay); + options::set(options::Option::PurgeDelay, 1); + let mut big = Vec::new(); + for _ in 0..4 { + big.push(mi::malloc(8 * MIB)); + } + for p in big.drain(..) { + mi::free(p); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + mi::collect(false); // expiry-driven purge + mi::collect(true); // forced purge + cache flush back to the OS + options::set(options::Option::PurgeDelay, prev); +} + +#[test] +fn os_direct_huge_segments() { + // Huge segments above the arena bitmap's single-claim limit (> 2 GiB) + // and odd-sized huge segments come straight from the OS; check_owned on + // them exercises the segment-cookie path (no arena contains them). + let p = mi::malloc(36 * MIB); // odd size: not a 32 MiB multiple + assert!(!p.is_null()); + if !mi::is_in_heap_region(p) { + assert!(unsafe { mi::check_owned(p) }); + } + unsafe { p.write_bytes(3, 64) }; + mi::free(p); + mi::collect(true); // drain the deferred-free queue +} + +#[test] +fn diagnostics_counters() { + let p = mi::malloc(100); + mi::free(p); + let counts = mi::os_syscall_counts(); + assert!(counts[0] > 0); // at least one mmap happened by now + assert!(mi::current_thread_count_dbg() >= 1); + let (abandoned, tries, hits) = mi::abandoned_stats(); + assert!(hits <= tries || abandoned == 0 || tries == 0); +} + +// --------------------------------------------------------------------------- +// Stats and options surfaces +// --------------------------------------------------------------------------- + +#[test] +fn stats_report_renders() { + let p = mi::malloc(100); + mi::free(p); + let mut buf = Vec::new(); + mi::stats_write(&mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("heap stats:")); + assert!(s.contains("pages ops:")); + assert!(s.contains("segments")); + mi::stats_print(); // stderr variant + + // a writer failing at every possible point exercises each `?` arm + struct Failing(usize); + impl std::io::Write for Failing { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.0 == 0 { + return Err(std::io::Error::other("full")); + } + self.0 -= 1; + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + for budget in 0..32 { + let _ = mi::stats_write(&mut Failing(budget)); + } +} + +#[test] +fn options_set_get_enable_disable() { + use rimalloc::options; + let prev = options::get(options::Option::MaxPageCandidates); + options::set(options::Option::MaxPageCandidates, 9); + assert_eq!(options::get(options::Option::MaxPageCandidates), 9); + options::enable(options::Option::MaxPageCandidates); + assert!(options::is_enabled(options::Option::MaxPageCandidates)); + options::disable(options::Option::MaxPageCandidates); + assert!(!options::is_enabled(options::Option::MaxPageCandidates)); + options::set(options::Option::MaxPageCandidates, prev); +} + +// --------------------------------------------------------------------------- +// Thread lifecycle: explicit init/done and heaps left at thread exit +// --------------------------------------------------------------------------- + +#[test] +fn explicit_thread_init_done() { + std::thread::spawn(|| { + mi::thread_init(); + mi::thread_init(); // idempotent + let p = mi::malloc(100); + assert!(!p.is_null()); + mi::free(p); + mi::thread_done(); + mi::thread_done(); // idempotent + }) + .join() + .unwrap(); +} + +#[test] +fn thread_exit_deletes_extra_heaps() { + std::thread::spawn(|| { + let heap = mi::heap_new().expect("heap"); + for _ in 0..64 { + assert!(!mi::heap_malloc(heap, 512).is_null()); + } + // exit without heap_delete: thread teardown must absorb + abandon + }) + .join() + .unwrap(); + mi::collect(true); +} + +// --------------------------------------------------------------------------- +// Block sizes above 4 GiB (fastmod reciprocal unusable) +// --------------------------------------------------------------------------- + +#[test] +fn block_size_above_reciprocal_range() { + // > 4 GiB block: page.block_recip stays 0 and usable_size/free fall + // back to plain division. Virtual-only; pages are never touched beyond + // the first one. + let size = (4 << 30) + 4096usize; + let p = mi::malloc(size); + if p.is_null() { + return; // acceptable under memory pressure + } + unsafe { p.write_bytes(7, 8) }; + assert!(mi::usable_size(p) >= size); + mi::free(p); + mi::collect(true); +} diff --git a/vendor/rimalloc/tests/debug_fill.rs b/vendor/rimalloc/tests/debug_fill.rs new file mode 100644 index 00000000..61021634 --- /dev/null +++ b/vendor/rimalloc/tests/debug_fill.rs @@ -0,0 +1,35 @@ +//! Port of the `MI_DEBUG>=2` half of mimalloc `test/test-api-fill.c`: +//! fresh blocks carry the 0xD0 uninit pattern, freed blocks 0xDF. +#![cfg(feature = "debug-fill")] + +use rimalloc as mi; + +fn check_fill(p: *const u8, size: usize, byte: u8) -> bool { + (0..size).all(|i| unsafe { *p.add(i) } == byte) +} + +#[test] +fn uninit_malloc_small_and_large() { + for size in [8usize, 128, 1024, 16 * 1024] { + let p = mi::malloc(size); + assert!(check_fill(p, size, 0xD0), "uninit fill, size {size}"); + mi::free(p); + } +} + +#[test] +fn fill_freed_small() { + let size = 256usize; + let p = mi::malloc(size); + unsafe { std::ptr::write_bytes(p, 0xab, size) }; + mi::free(p); + // skip the first word: it now holds the free-list link + assert!(check_fill(unsafe { p.add(8) }, size - 8, 0xDF)); +} + +#[test] +fn zalloc_still_zero() { + let p = mi::zalloc(512); + assert!(check_fill(p, 512, 0)); + mi::free(p); +} diff --git a/vendor/rimalloc/tests/fill.rs b/vendor/rimalloc/tests/fill.rs new file mode 100644 index 00000000..94f471d3 --- /dev/null +++ b/vendor/rimalloc/tests/fill.rs @@ -0,0 +1,172 @@ +//! Port of mimalloc `test/test-api-fill.c` (the zero-init half; the +//! `MI_DEBUG>=2` fill-pattern half tests debug fill values we do not +//! implement, matching a release-mode mimalloc build). + +use rimalloc as mi; + +const SMALL_SIZE_MAX: usize = 128 * size_of::(); +const MAX_ALIGN_SIZE: usize = 16; + +fn check_zero_init(p: *const u8, size: usize) -> bool { + !p.is_null() && (0..size).all(|i| unsafe { *p.add(i) } == 0) +} + +#[test] +fn zeroinit_zalloc_small() { + let size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc(size); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_zalloc_large() { + let size = SMALL_SIZE_MAX * 2; + let p = mi::zalloc(size); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_zalloc_small_fn() { + let size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc_small(size); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_calloc_small() { + let size = SMALL_SIZE_MAX / 2; + let p = mi::calloc(size, 1); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_calloc_large() { + let size = SMALL_SIZE_MAX * 2; + let p = mi::calloc(size, 1); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_rezalloc_small() { + let mut size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc(size); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::rezalloc(p, size); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_rezalloc_large() { + let mut size = SMALL_SIZE_MAX * 2; + let p = mi::zalloc(size); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::rezalloc(p, size); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_recalloc_small() { + let mut size = SMALL_SIZE_MAX / 2; + let p = mi::calloc(size, 1); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::recalloc(p, size, 1); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_recalloc_large() { + let mut size = SMALL_SIZE_MAX * 2; + let p = mi::calloc(size, 1); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::recalloc(p, size, 1); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_zalloc_aligned_small() { + let size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_zalloc_aligned_large() { + let size = SMALL_SIZE_MAX * 2; + let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_calloc_aligned_small() { + let size = SMALL_SIZE_MAX / 2; + let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_calloc_aligned_large() { + let size = SMALL_SIZE_MAX * 2; + let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_rezalloc_aligned_small() { + let mut size = SMALL_SIZE_MAX / 2; + let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::rezalloc_aligned(p, size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_rezalloc_aligned_large() { + let mut size = SMALL_SIZE_MAX * 2; + let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::rezalloc_aligned(p, size, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_recalloc_aligned_small() { + let mut size = SMALL_SIZE_MAX / 2; + let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::recalloc_aligned(p, size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} + +#[test] +fn zeroinit_recalloc_aligned_large() { + let mut size = SMALL_SIZE_MAX * 2; + let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + size *= 3; + let p = mi::recalloc_aligned(p, size, 1, MAX_ALIGN_SIZE * 2); + assert!(check_zero_init(p, size)); + mi::free(p); +} diff --git a/vendor/rimalloc/tests/fork.rs b/vendor/rimalloc/tests/fork.rs new file mode 100644 index 00000000..05ae0777 --- /dev/null +++ b/vendor/rimalloc/tests/fork.rs @@ -0,0 +1,72 @@ +//! fork() safety: the child of a multithreaded allocating process must be +//! able to keep allocating (locks are held across fork and released in the +//! child; another thread's mid-critical-section state cannot leak in). +//! Miri cannot emulate `fork`, so this target is skipped under Miri. +#![cfg(all(unix, not(miri)))] + +use std::sync::atomic::{AtomicBool, Ordering}; + +#[test] +fn fork_while_allocating() { + // hammer the allocator (and its locks) from background threads + static STOP: AtomicBool = AtomicBool::new(false); + let workers: Vec<_> = (0..4) + .map(|t| { + std::thread::spawn(move || { + let mut keep = Vec::new(); + while !STOP.load(Ordering::Relaxed) { + let p = rimalloc::malloc((t + 1) * 137); + keep.push(p); + if keep.len() > 64 { + for q in keep.drain(..) { + rimalloc::free(q); + } + } + } + for q in keep { + rimalloc::free(q); + } + }) + }) + .collect(); + + for _ in 0..20 { + // SAFETY: child only uses the allocator and _exit (async-signal + // considerations handled by our atfork hooks). + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + // child: allocate/free across many size classes, then exit + let mut ptrs = Vec::new(); + for i in 0..2000usize { + let p = rimalloc::malloc((i % 3000) + 1); + if p.is_null() { + unsafe { libc::_exit(2) }; + } + unsafe { p.write_bytes(0x5c, (i % 3000) + 1) }; + ptrs.push(p); + } + for p in ptrs { + rimalloc::free(p); + } + let big = rimalloc::malloc(50 << 20); // force fresh segment work + if big.is_null() { + unsafe { libc::_exit(3) }; + } + rimalloc::free(big); + unsafe { libc::_exit(0) }; + } + let mut status = 0; + // SAFETY: valid pid from fork above. + unsafe { libc::waitpid(pid, &mut status, 0) }; + assert!( + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + "child failed: status={status:#x}" + ); + } + + STOP.store(true, Ordering::Relaxed); + for w in workers { + w.join().unwrap(); + } +} diff --git a/vendor/rimalloc/tests/lean_conformance.rs b/vendor/rimalloc/tests/lean_conformance.rs new file mode 100644 index 00000000..2f5b25d8 --- /dev/null +++ b/vendor/rimalloc/tests/lean_conformance.rs @@ -0,0 +1,94 @@ +//! Conformance against the Lean-verified model (`verify/`). +//! +//! The Lean project proves theorems about a reference model; these tests +//! pin the live Rust implementation to that model over the *entire* input +//! domain. Refactor the Rust freely: if it still passes here, every Lean +//! theorem (fit, <25% fragmentation, monotonicity, range, round-trip) +//! transfers to the new code. Regenerate the vectors (`lake exe +//! genvectors`) only when the spec itself changes. + +use rimalloc::lean_model; + +#[cfg(not(windows))] +#[test] +fn bins_match_lean_model() { + let csv = include_str!("../../../verify/vectors/bins.csv"); + let mut checked = 0; + for line in csv.lines() { + let (w, bin) = line.split_once(',').expect("w,bin"); + let w: usize = w.parse().unwrap(); + let expect: usize = bin.parse().unwrap(); + assert_eq!( + lean_model::bin_from_wsize(w), + expect, + "Bin::from_size diverged from the verified model at wsize {w}" + ); + checked += 1; + } + assert_eq!(checked, 8194); // the full domain plus one past it +} + +#[cfg(not(windows))] +#[test] +fn bin_sizes_match_lean_model() { + let csv = include_str!("../../../verify/vectors/bin_sizes.csv"); + for line in csv.lines() { + let (bin, wsize) = line.split_once(',').expect("bin,wsize"); + let bin: usize = bin.parse().unwrap(); + let expect: usize = wsize.parse().unwrap(); + assert_eq!( + lean_model::bin_wsize(bin), + expect, + "BIN_WSIZE diverged from the verified model at bin {bin}" + ); + } +} + +#[test] +fn commit_mask_matches_closed_form() { + // Lean proves the create loop equals ((1 << count) - 1) << idx + // (`CommitMask.create_correct`); check the live Rust loop against the + // same closed form for every valid (idx, count) pair. Under Miri the + // ~67M-assert grid is sampled with a coprime stride (the full grid + // still runs natively, and the property is Lean-proven). + let step = if cfg!(miri) { 37 } else { 1 }; + for idx in (0..=512usize).step_by(step) { + for count in (0..=(512 - idx)).step_by(step) { + let mask = lean_model::commit_mask_create_words(idx, count); + for bit in 0..512 { + let expect = bit >= idx && bit < idx + count; + let got = mask[bit / 64] & (1 << (bit % 64)) != 0; + assert_eq!(got, expect, "create({idx},{count}) bit {bit}"); + } + } + } +} + +#[test] +fn page_start_offset_matches_lean_contract() { + // PageStart.lean proves three properties of the offset computation over + // every reachable bin <= MAX_ALIGN_GUARANTEE and every 16-aligned + // page-start residue; check the live function against the same + // contract over the same (exhaustive) domain. + for bin in 1..49usize { + let bs = lean_model::bin_wsize(bin) * 8; + let reachable = lean_model::bin_from_wsize(lean_model::bin_wsize(bin)) == bin; + let psize = if bs <= 8192 { 65536 } else { 524288 }; + for k in 0..4096usize { + let pstart = 16 * k; + let off = lean_model::page_start_offset(bs, pstart, psize); + // pageStart_align16 + assert_eq!(off % 16, 0, "16-alignment, bs={bs} pstart={pstart}"); + if reachable { + // pageStart_blockAligned + assert_eq!( + (pstart + off) % bs, + 0, + "block alignment guarantee, bs={bs} pstart={pstart}" + ); + // pageStart_fits + assert!(off + bs <= psize, "fits, bs={bs} pstart={pstart}"); + } + } + } +} diff --git a/vendor/rimalloc/tests/loom_arena.rs b/vendor/rimalloc/tests/loom_arena.rs new file mode 100644 index 00000000..ddbfd902 --- /dev/null +++ b/vendor/rimalloc/tests/loom_arena.rs @@ -0,0 +1,328 @@ +//! Loom model-check of the arena claim/commit/purge bitmap protocol. +//! +//! This drives the *actual implementation* — `rimalloc::bitmap::{ +//! claim_single_field, claim_run_field, prepare_run, release_run, +//! clear_committed_run, purge_field, decommit_run}`, the same generic +//! functions production `arena.rs` executes — instantiated with loom +//! atomics, so every CAS, ordering, and bit transition of the real lock-free +//! bitmap code is explored across all interleavings. Only the OS side +//! effects are test-side: `commit`/`decommit` set/clear a per-block shadow +//! flag (loom cannot model real mmap) and "use" asserts that flag, mirroring +//! how production supplies the real syscalls through the same closures. +//! +//! Invariants asserted across all interleavings: +//! 1. Mutual exclusion — no two threads hold the same block at once (the +//! lost-update / double-claim hazard, lock-free single-block CAS path and +//! the multi-block spinlock-scan path). +//! 2. Commit-before-use (the SIGBUS class) — a claimer never reads a block +//! whose committed shadow is clear without having recommitted; the +//! `blocks_committed` bit and the real commit state never diverge to hand +//! out PROT_NONE memory. +//! 3. Purge/claim non-interference — a block being decommitted by +//! `purge_field`/`decommit_run` is never simultaneously used by a claimer. +//! +//! Run: LOOM_MAX_PREEMPTIONS=3 RUSTFLAGS="--cfg loom" \ +//! cargo test -p rimalloc --test loom_arena --release +#![cfg(loom)] + +use loom::cell::UnsafeCell; +use loom::sync::Arc; +use loom::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use loom::thread; +use rimalloc::bitmap::{ + BitmapWord, claim_run_field, claim_single_field, clear_committed_run, decommit_run, + prepare_run, purge_field, release_run, +}; + +/// Loom-instrumented atomic word for the bitmap's generic interface. +struct LoomWord(AtomicUsize); +impl LoomWord { + fn new(v: usize) -> LoomWord { + LoomWord(AtomicUsize::new(v)) + } +} +impl BitmapWord for LoomWord { + fn load(&self, o: Ordering) -> usize { + self.0.load(o) + } + fn store(&self, v: usize, o: Ordering) { + self.0.store(v, o) + } + fn swap(&self, v: usize, o: Ordering) -> usize { + self.0.swap(v, o) + } + fn fetch_or(&self, v: usize, o: Ordering) -> usize { + self.0.fetch_or(v, o) + } + fn fetch_and(&self, v: usize, o: Ordering) -> usize { + self.0.fetch_and(v, o) + } + fn compare_exchange_weak( + &self, + cur: usize, + new: usize, + ok: Ordering, + err: Ordering, + ) -> Result { + self.0.compare_exchange_weak(cur, new, ok, err) + } +} + +/// One arena field's worth of bitmap state plus the test-side shadows that +/// stand in for real OS memory. `BLOCKS` blocks in a single field. +struct Field { + inuse: LoomWord, + dirty: LoomWord, + committed: LoomWord, + purge: LoomWord, + claim_lock: AtomicBool, + /// Per-block shadow of the *real* commit state: `commit` sets it, + /// `decommit` clears it, "use" asserts it. Divergence from the + /// `committed` bitmap in a way that lets a claimer use a `false` block is + /// the SIGBUS bug. + shadow_committed: Vec>, + /// Per-block live-owner guard for mutual exclusion: a claimer that thinks + /// it owns a block flips this true; two simultaneous owners is a + /// double-claim. Guarded solely by the bitmap CAS, never atomically. + owned: Vec>, +} + +// SAFETY: all sharing is mediated by the loom atomics / cells. +unsafe impl Sync for Field {} +unsafe impl Send for Field {} + +impl Field { + fn new(blocks: usize, committed: bool) -> Field { + Field { + inuse: LoomWord::new(0), + dirty: LoomWord::new(0), + committed: LoomWord::new(if committed { (1 << blocks) - 1 } else { 0 }), + purge: LoomWord::new(0), + claim_lock: AtomicBool::new(false), + shadow_committed: (0..blocks).map(|_| UnsafeCell::new(committed)).collect(), + owned: (0..blocks).map(|_| UnsafeCell::new(false)).collect(), + } + } + + /// `commit` side effect: set the shadow flags for the run. + fn shadow_commit(&self, bit: usize, count: usize) { + for b in bit..bit + count { + self.shadow_committed[b].with_mut(|p| unsafe { *p = true }); + } + } + + /// `decommit` side effect: clear the shadow flags for the run. + fn shadow_decommit(&self, bit: usize, count: usize) { + for b in bit..bit + count { + self.shadow_committed[b].with_mut(|p| unsafe { *p = false }); + } + } + + /// Claim + prepare + use + release one block via the *real* lock-free + /// single-block path, asserting all three invariants. + fn claim_use_single(&self, blocks: usize) { + let Some(bit) = claim_single_field(&self.inuse, blocks) else { + return; // field full this interleaving + }; + // Invariant 1: we are the sole owner now. + self.owned[bit].with_mut(|p| unsafe { + assert!(!*p, "double-claim: block {bit} already owned"); + *p = true; + }); + // prepare: recommit iff committed bit was clear. + prepare_run(&self.dirty, &self.committed, &self.purge, bit, 1, || { + self.shadow_commit(bit, 1) + }); + // Invariant 2: the block we are about to "use" is really committed. + self.shadow_committed[bit] + .with(|p| unsafe { assert!(*p, "commit-before-use violated: block {bit} (SIGBUS)") }); + // Invariant 3 also holds here: purge_field/decommit_run can only act + // on blocks whose inuse bit is clear, and ours is set for this whole + // window. + self.owned[bit].with_mut(|p| unsafe { *p = false }); + release_run(&self.inuse, &self.purge, bit, 1); + } + + /// Claim + prepare + use + release a `count`-wide run via the *real* + /// multi-block spinlock-scan path. + fn claim_use_run(&self, blocks: usize, count: usize) { + rimalloc_spin_acquire(&self.claim_lock); + let found = claim_run_field(&self.inuse, blocks, count); + rimalloc_spin_release(&self.claim_lock); + let Some(bit) = found else { + return; + }; + for b in bit..bit + count { + self.owned[b].with_mut(|p| unsafe { + assert!(!*p, "double-claim (multi): block {b} already owned"); + *p = true; + }); + } + prepare_run( + &self.dirty, + &self.committed, + &self.purge, + bit, + count, + || self.shadow_commit(bit, count), + ); + for b in bit..bit + count { + self.shadow_committed[b].with(|p| unsafe { + assert!(*p, "commit-before-use violated: block {b} (SIGBUS, multi)") + }); + } + for b in bit..bit + count { + self.owned[b].with_mut(|p| unsafe { *p = false }); + } + release_run(&self.inuse, &self.purge, bit, count); + } + + /// `arena::free(.., all_committed=false)`: a dying segment decommitted + /// part of its range, so it clears the committed bits *and* the shadow + /// for the run, then releases. Models the cross-layer fix's caller. + fn free_partial(&self, bit: usize, count: usize) { + clear_committed_run(&self.committed, bit, count); + self.shadow_decommit(bit, count); + release_run(&self.inuse, &self.purge, bit, count); + } + + /// `purge_now` for this single field, decommit mode: the real + /// `purge_field` + `decommit_run` handshake. The `decommit` closure + /// clears the shadow; invariant 3 is that no claimer uses a block while + /// its shadow is being cleared — guaranteed because `decommit_run` holds + /// the inuse bit across the whole shadow clear, so any concurrent + /// claimer's `prepare_run` (which sees committed clear) recommits before + /// using, and any claimer that grabbed the bit first keeps `decommit_run` + /// from acting on it. + fn purge(&self) { + purge_field(&self.purge, &self.inuse, |bit, run_len| { + decommit_run(&self.inuse, &self.committed, bit, run_len, || { + self.shadow_decommit(bit, run_len) + }); + }); + } + + /// Final consistency check: every committed *bit* must agree with its + /// shadow (no block advertised committed in the bitmap while physically + /// decommitted), and nothing is left owned. + fn assert_consistent(&self, blocks: usize) { + let inuse = self.inuse.load(Ordering::Relaxed); + assert_eq!(inuse, 0, "blocks left in use"); + let committed = self.committed.load(Ordering::Relaxed); + for b in 0..blocks { + self.owned[b].with(|p| unsafe { assert!(!*p, "block {b} left owned") }); + let bit_set = committed & (1 << b) != 0; + let shadow = self.shadow_committed[b].with(|p| unsafe { *p }); + if bit_set { + assert!( + shadow, + "committed bitmap advertises block {b} but shadow says decommitted" + ); + } + } + } +} + +/// Loom-instrumented copy of `crate::sync::spin_{acquire,release}` (the lock +/// is `private`; this is byte-identical to the production spinlock so the +/// multi-block claim path is modeled exactly). +fn rimalloc_spin_acquire(lock: &AtomicBool) { + while lock + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + loom::hint::spin_loop(); + } +} +fn rimalloc_spin_release(lock: &AtomicBool) { + lock.store(false, Ordering::Release); +} + +/// Two threads race lock-free single-block claims on a 2-block field that +/// starts fully committed. Mutual exclusion + commit-before-use. +#[test] +fn claim_vs_claim_single() { + loom::model(|| { + let f = Arc::new(Field::new(2, true)); + let t1 = { + let f = f.clone(); + thread::spawn(move || f.claim_use_single(2)) + }; + let t2 = { + let f = f.clone(); + thread::spawn(move || f.claim_use_single(2)) + }; + t1.join().unwrap(); + t2.join().unwrap(); + f.assert_consistent(2); + }); +} + +/// A claimer racing a purge: block 0 starts free+committed but scheduled for +/// purge; one thread claims-and-uses it (must recommit if it observes the +/// committed bit cleared by the purge), the other decommits it. This is the +/// exact SIGBUS class — re-claiming a partially-decommitted block. +#[test] +fn claim_vs_purge() { + loom::model(|| { + // 2 blocks, both committed; block 0 scheduled for purge. + let f = Arc::new(Field::new(2, true)); + f.purge.store(1, Ordering::Relaxed); // block 0 pending purge + let claimer = { + let f = f.clone(); + thread::spawn(move || f.claim_use_single(2)) + }; + let purger = { + let f = f.clone(); + thread::spawn(move || f.purge()) + }; + claimer.join().unwrap(); + purger.join().unwrap(); + f.assert_consistent(2); + }); +} + +/// free-partial (clears committed bit + shadow) racing a claim-and-use of the +/// freed block: the claimer that re-grabs the block must observe the cleared +/// committed bit and recommit before using. This is the cross-layer fix. +#[test] +fn free_partial_vs_claim() { + loom::model(|| { + // 1 block, starts owned (inuse) and committed. + let f = Arc::new(Field::new(1, true)); + f.inuse.store(1, Ordering::Relaxed); + f.dirty.store(1, Ordering::Relaxed); + let freer = { + let f = f.clone(); + thread::spawn(move || f.free_partial(0, 1)) + }; + let claimer = { + let f = f.clone(); + thread::spawn(move || f.claim_use_single(1)) + }; + freer.join().unwrap(); + claimer.join().unwrap(); + f.assert_consistent(1); + }); +} + +/// Multi-block claim (spinlock path) racing a single-block claim on a 2-block +/// field: the run claim wants both blocks; the single claim wants one. They +/// must not overlap. +#[test] +fn claim_run_vs_single() { + loom::model(|| { + let f = Arc::new(Field::new(2, true)); + let runner = { + let f = f.clone(); + thread::spawn(move || f.claim_use_run(2, 2)) + }; + let single = { + let f = f.clone(); + thread::spawn(move || f.claim_use_single(2)) + }; + runner.join().unwrap(); + single.join().unwrap(); + f.assert_consistent(2); + }); +} diff --git a/vendor/rimalloc/tests/loom_protocol.rs b/vendor/rimalloc/tests/loom_protocol.rs new file mode 100644 index 00000000..e8aae06d --- /dev/null +++ b/vendor/rimalloc/tests/loom_protocol.rs @@ -0,0 +1,215 @@ +//! Loom model-check of the cross-thread delayed-free protocol. +//! +//! This drives the *actual implementation* — `rimalloc::protocol::{mt_push, +//! collect_take, try_use_delayed, delayed_partial}`, the same generic +//! functions production `free.rs`/`page_ops.rs` execute — instantiated +//! with loom atomics, so every CAS, ordering, and flag transition of the +//! real code is explored across all interleavings. Only block *storage* +//! (next links, the owner's counters) is test-side, mirroring how +//! production supplies raw page memory through the same closures. +//! +//! Checked: every remotely freed block lands on the owner's local list +//! exactly once (none lost, none duplicated), `used` reaches exactly zero, +//! and FREEING never leaks. +//! +//! Run: RUSTFLAGS="--cfg loom" cargo test --test loom_protocol --release +#![cfg(loom)] + +use loom::cell::UnsafeCell; +use loom::sync::Arc; +use loom::sync::atomic::Ordering; +use loom::thread; +use rimalloc::protocol::{ + AtomicWord, FREEING, NO_DELAYED, TF_COUNT_SHIFT, TF_PTR_MASK, USE_DELAYED, collect_take, + delayed_partial, mt_push, try_use_delayed, +}; + +/// Loom-instrumented atomic word for the protocol's generic interface. +struct LoomWord(loom::sync::atomic::AtomicUsize); +impl AtomicWord for LoomWord { + fn load(&self, o: core::sync::atomic::Ordering) -> usize { + self.0.load(o) + } + fn store(&self, v: usize, o: core::sync::atomic::Ordering) { + self.0.store(v, o) + } + fn swap(&self, v: usize, o: core::sync::atomic::Ordering) -> usize { + self.0.swap(v, o) + } + fn compare_exchange_weak( + &self, + cur: usize, + new: usize, + ok: core::sync::atomic::Ordering, + err: core::sync::atomic::Ordering, + ) -> Result { + self.0.compare_exchange_weak(cur, new, ok, err) + } +} + +/// Test-side storage standing in for raw page memory: block addresses are +/// `4 * (index + 1)` so flag bits never collide and 0 stays null. +struct Storage { + next: Vec>, + xthread: LoomWord, + delayed: LoomWord, + used: UnsafeCell, + local: UnsafeCell>, +} +unsafe impl Sync for Storage {} +unsafe impl Send for Storage {} + +const fn addr(i: usize) -> usize { + 4 * (i + 1) +} +fn index(a: usize) -> usize { + a / 4 - 1 +} + +impl Storage { + fn new(n: usize, flag: usize) -> Storage { + Storage { + next: (0..n).map(|_| UnsafeCell::new(0)).collect(), + xthread: LoomWord(loom::sync::atomic::AtomicUsize::new(flag)), + delayed: LoomWord(loom::sync::atomic::AtomicUsize::new(0)), + used: UnsafeCell::new(n), + local: UnsafeCell::new(Vec::new()), + } + } + + /// `free_block_delayed_mt` = the real `mt_push` + raw storage closures. + fn remote_free(&self, i: usize) { + mt_push( + &self.xthread, + || Some(&self.delayed), + addr(i), + |next| self.next[i].with_mut(|p| unsafe { *p = next }), + ); + } + + /// `page_thread_free_collect` = the real `collect_take` + list walk. + /// The walk must agree with the count packed in the taken word. + fn collect(&self) { + let taken = collect_take(&self.xthread); + let mut n = taken & TF_PTR_MASK; + let mut count = 0usize; + while n != 0 { + count += 1; + let next = self.next[index(n)].with(|p| unsafe { *p }); + self.local.with_mut(|l| unsafe { (*l).push(n) }); + n = next; + } + assert_eq!(taken >> TF_COUNT_SHIFT, count, "packed count wrong"); + self.used.with_mut(|u| unsafe { + assert!(*u >= count, "used underflow in collect"); + *u -= count; + }); + } + + /// `free_delayed_block` = the real `try_use_delayed` + collect + + /// local bookkeeping. + fn free_delayed_block(&self, a: usize) -> bool { + if !try_use_delayed(&self.xthread, USE_DELAYED, false, thread::yield_now) { + return false; + } + self.collect(); + self.local.with_mut(|l| unsafe { (*l).push(a) }); + self.used.with_mut(|u| unsafe { + assert!(*u >= 1, "used underflow (delayed)"); + *u -= 1; + }); + true + } + + /// `heap_delayed_free_partial` = the real `delayed_partial`. + fn drain_delayed(&self) -> bool { + delayed_partial( + &self.delayed, + |a| self.next[index(a)].with(|p| unsafe { *p }), + |a, next| self.next[index(a)].with_mut(|p| unsafe { *p = next }), + |a| self.free_delayed_block(a), + ) + } + + fn assert_quiescent(&self, n: usize) { + for _ in 0..4 { + self.drain_delayed(); + self.collect(); + } + assert_eq!( + self.delayed.load(Ordering::Relaxed), + 0, + "delayed not drained" + ); + let x = self.xthread.load(Ordering::Relaxed); + assert_ne!(x & 3, FREEING, "FREEING leaked"); + assert_eq!(x & !3, 0, "page list not drained"); + self.used + .with(|u| unsafe { assert_eq!(*u, 0, "used not zero") }); + self.local.with(|l| unsafe { + let mut v = (*l).clone(); + v.sort(); + assert_eq!( + v, + (0..n).map(addr).collect::>(), + "blocks lost or duplicated" + ); + }); + } +} + +/// Full page (USE_DELAYED): the first remote free must take the FREEING +/// handshake and wake the owner via the delayed list; nothing may be lost +/// between the two lists. +#[test] +fn full_page_wakeup() { + loom::model(|| { + let s = Arc::new(Storage::new(2, USE_DELAYED)); + let r1 = { + let s = s.clone(); + thread::spawn(move || s.remote_free(0)) + }; + let r2 = { + let s = s.clone(); + thread::spawn(move || s.remote_free(1)) + }; + let owner = { + let s = s.clone(); + thread::spawn(move || { + s.drain_delayed(); + }) + }; + r1.join().unwrap(); + r2.join().unwrap(); + owner.join().unwrap(); + s.assert_quiescent(2); + }); +} + +/// Normal page (NO_DELAYED): remote frees race the owner's collect on the +/// page-local thread-free list. +#[test] +fn page_list_race() { + loom::model(|| { + let s = Arc::new(Storage::new(2, NO_DELAYED)); + let r1 = { + let s = s.clone(); + thread::spawn(move || s.remote_free(0)) + }; + let r2 = { + let s = s.clone(); + thread::spawn(move || s.remote_free(1)) + }; + let owner = { + let s = s.clone(); + thread::spawn(move || { + s.collect(); + s.drain_delayed(); + }) + }; + r1.join().unwrap(); + r2.join().unwrap(); + owner.join().unwrap(); + s.assert_quiescent(2); + }); +} diff --git a/vendor/rimalloc/tests/mt_stress.rs b/vendor/rimalloc/tests/mt_stress.rs new file mode 100644 index 00000000..c3056a4d --- /dev/null +++ b/vendor/rimalloc/tests/mt_stress.rs @@ -0,0 +1,144 @@ +//! Deterministic multithreaded stress: the same op-program interpreter as +//! the `mt_ops` fuzz target, driven by fixed PRNG-generated programs so the +//! cross-thread paths (`free_generic_mt`, thread-exit abandonment, +//! reclaim-on-free) run under `cargo test` — and, critically, under the +//! TSan battery (`scripts/tsan.sh`), where loom's extracted-protocol model +//! cannot see segment reclaim, arena bitmaps, or page lifecycle. + +#![cfg(not(miri))] // covered by loom/fuzz; threads under Miri are too slow + +use std::sync::mpsc; + +struct Block(*mut u8, usize, u8); +unsafe impl Send for Block {} + +fn fill(p: *mut u8, n: usize, pat: u8) { + unsafe { core::ptr::write_bytes(p, pat, n.min(4096)) } +} + +fn check(b: &Block) { + for i in 0..b.1.min(4096) { + assert_eq!( + unsafe { *b.0.add(i) }, + b.2, + "corruption in cross-thread block" + ); + } +} + +/// The `mt_ops` fuzz-target body, verbatim. +fn run_program(data: &[u8]) { + if data.len() < 4 { + return; + } + let n_remote = (data[0] % 3 + 1) as usize; + let ops = &data[1..]; + + let mut to_remote: Vec> = Vec::new(); + let mut handles = Vec::new(); + let (back_tx, back_rx) = mpsc::channel::(); + for t in 0..n_remote { + let (tx, rx) = mpsc::channel::(); + to_remote.push(tx); + let back = back_tx.clone(); + let seed = data.get(2 + t).copied().unwrap_or(7); + handles.push(std::thread::spawn(move || { + let mut k = 0u8; + while let Ok(b) = rx.recv() { + check(&b); + rimalloc::free(b.0); + let size = ((seed as usize * 131 + k as usize * 977) % 9000) + 1; + let p = rimalloc::malloc(size); + if !p.is_null() { + let pat = seed ^ k | 1; + fill(p, size, pat); + if k % 3 == 0 { + let _ = back.send(Block(p, size, pat)); + } else { + check(&Block(p, size, pat)); + rimalloc::free(p); + } + } + k = k.wrapping_add(1); + } + })); + } + drop(back_tx); + + let mut local: Vec = Vec::new(); + let mut pat = 0x10u8; + for (i, &op) in ops.iter().enumerate() { + match op % 5 { + 0 | 1 => { + let size = ((op as usize) << 5 | i & 31) + 1; + let p = rimalloc::malloc(size); + if !p.is_null() { + pat = pat.wrapping_add(1) | 1; + fill(p, size, pat); + let _ = to_remote[i % n_remote].send(Block(p, size, pat)); + } + } + 2 => { + let size = (op as usize * 67 % 16384) + 1; + let p = rimalloc::malloc(size); + if !p.is_null() { + pat = pat.wrapping_add(1) | 1; + fill(p, size, pat); + local.push(Block(p, size, pat)); + } + } + 3 => { + if let Ok(b) = back_rx.try_recv() { + check(&b); + rimalloc::free(b.0); + } + } + _ => { + if !local.is_empty() { + let b = local.swap_remove(op as usize % local.len()); + check(&b); + rimalloc::free(b.0); + } + } + } + } + + drop(to_remote); + for h in handles { + h.join().unwrap(); + } + while let Ok(b) = back_rx.recv() { + check(&b); + rimalloc::free(b.0); + } + for b in local.drain(..) { + check(&b); + rimalloc::free(b.0); + } + rimalloc::collect(false); +} + +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e3779b97f4a7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); + z ^ (z >> 31) +} + +fn program(seed: u64, len: usize) -> Vec { + let mut state = seed; + (0..len).map(|_| splitmix64(&mut state) as u8).collect() +} + +#[test] +fn mt_ops_programs() { + for seed in 0..8u64 { + run_program(&program(seed, 512)); + } +} + +#[test] +fn mt_ops_long_program() { + run_program(&program(0xdead_beef, 4096)); +} diff --git a/vendor/rimalloc/tests/no_arena_cache.rs b/vendor/rimalloc/tests/no_arena_cache.rs new file mode 100644 index 00000000..5997c67d --- /dev/null +++ b/vendor/rimalloc/tests/no_arena_cache.rs @@ -0,0 +1,54 @@ +//! Exercises the OS segment cache, which is only reachable when arena +//! reservation is disabled (`MIMALLOC_ARENA_RESERVE=0`). Lives in its own +//! test binary: options latch on first read, so the env must be set +//! before the first allocator call in this process. + +#[test] +fn segment_cache_serves_reuse_without_arenas() { + // SAFETY: single test in this binary; no concurrent env access. + unsafe { + std::env::set_var("MIMALLOC_ARENA_RESERVE", "0"); + std::env::set_var("MIMALLOC_PURGE_DELAY", "0"); + } + + // Wave 1: force several segments into existence, then free everything + // so the segments retire into the OS cache. + let mut ptrs: Vec<*mut u8> = (0..64) + .map(|i| rimalloc::malloc(64 * 1024 + i * 64)) + .collect(); + for &p in &ptrs { + assert!(!p.is_null()); + unsafe { p.write_bytes(0xAB, 64) }; + } + for p in ptrs.drain(..) { + rimalloc::free(p); + } + rimalloc::collect(true); + let [mmap1, ..] = rimalloc::os_syscall_counts(); + + // Wave 2: the same demand must be served from the cache, not mmap. + let ptrs: Vec<*mut u8> = (0..64) + .map(|i| rimalloc::malloc(64 * 1024 + i * 64)) + .collect(); + for &p in &ptrs { + assert!(!p.is_null()); + } + let [mmap2, ..] = rimalloc::os_syscall_counts(); + assert!( + mmap2 <= mmap1 + 2, + "cache miss storm: {mmap1} mmaps before wave 2, {mmap2} after" + ); + for p in ptrs { + rimalloc::free(p); + } + + // Expire and purge the cached segments (purge_delay=0 → immediate). + rimalloc::collect(true); + + // Huge allocations bypass the cache (non-standard size) but must still + // work without arenas. + let huge = rimalloc::malloc(40 << 20); + assert!(!huge.is_null()); + unsafe { huge.write_bytes(1, 4096) }; + rimalloc::free(huge); +} diff --git a/vendor/rimalloc/tests/options_env.rs b/vendor/rimalloc/tests/options_env.rs new file mode 100644 index 00000000..50da6477 --- /dev/null +++ b/vendor/rimalloc/tests/options_env.rs @@ -0,0 +1,26 @@ +//! Environment-variable parsing for runtime options. Lives in its own +//! integration-test binary with a single test: the env must be set before +//! any option is first read (each option caches its value on first `get`), +//! and `set_var` must not race other test threads. + +use rimalloc::options::{self, Option as Opt}; + +#[test] +fn env_values_parse_with_fallback() { + // SAFETY: single test in this binary; no other thread reads the + // environment concurrently. + unsafe { + std::env::set_var("MIMALLOC_VERBOSE", "on"); + std::env::set_var("MIMALLOC_SHOW_STATS", "NO"); + std::env::set_var("MIMALLOC_SHOW_ERRORS", "3"); + std::env::set_var("MIMALLOC_PURGE_EXTEND_DELAY", ""); + std::env::set_var("MIMALLOC_EAGER_COMMIT_DELAY", "garbage"); + } + assert_eq!(options::get(Opt::Verbose), 1); // "on" => 1 + assert_eq!(options::get(Opt::ShowStats), 0); // "NO" => 0 (case-insensitive) + assert_eq!(options::get(Opt::ShowErrors), 3); // numeric + assert_eq!(options::get(Opt::PurgeExtendDelay), 1); // "" => 1 + assert_eq!(options::get(Opt::EagerCommitDelay), 0); // unparsable => default + // unset env: default applies + assert_eq!(options::get(Opt::FullPageRetain), 2); +} diff --git a/vendor/rimalloc/tests/parity.rs b/vendor/rimalloc/tests/parity.rs new file mode 100644 index 00000000..e3633caf --- /dev/null +++ b/vendor/rimalloc/tests/parity.rs @@ -0,0 +1,150 @@ +//! Tests for arena reservation/binding, NUMA plumbing, and registered hooks. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rimalloc as mi; + +#[test] +fn reserve_and_heap_in_arena() { + // Reserve an exclusive arena and bind a heap to it: allocations from + // that heap must come from inside the reservation; the default heap + // must never allocate from it. + const ARENA_SIZE: usize = 64 * 1024 * 1024; // 2 blocks + let arena_id = mi::reserve_os_memory(ARENA_SIZE, false, true).expect("reserve"); + assert!(arena_id > 0); + + let heap = mi::heap_new_in_arena(arena_id).expect("heap"); + let inside = mi::heap_malloc(heap, 1234); + assert!(!inside.is_null()); + + // exhaust nothing on the default heap; its memory must be elsewhere + let outside = mi::malloc(1234); + assert!(!outside.is_null()); + + // the exclusive arena served the bound heap + let p1 = mi::heap_malloc(heap, 100_000); + let p2 = mi::heap_malloc(heap, 8); + for p in [inside, p1, p2] { + assert!(!p.is_null()); + } + // all blocks of the bound heap live in the same 32MiB-aligned segment + // region; segments of the two heaps must differ + let seg = |p: *mut u8| p.addr() & !(32 * 1024 * 1024 - 1); + assert_ne!(seg(inside), seg(outside)); + + mi::free(p1); + mi::free(p2); + mi::heap_delete(heap); + mi::free(outside); +} + +#[cfg(not(windows))] +#[test] +fn manage_external_memory() { + // Hand rimalloc a raw mapping; it must become an allocatable arena. + const SIZE: usize = 96 * 1024 * 1024; + let raw = unsafe { + libc::mmap( + std::ptr::null_mut(), + SIZE, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ) + }; + assert_ne!(raw, libc::MAP_FAILED); + let arena_id = + unsafe { mi::manage_os_memory(raw.cast(), SIZE, true, true, -1, true).expect("manage") }; + let heap = mi::heap_new_in_arena(arena_id).expect("heap"); + let p = mi::heap_malloc(heap, 4096); + assert!(!p.is_null()); + assert!(p.addr() >= raw.addr() && p.addr() < raw.addr() + SIZE); + mi::free(p); + mi::heap_delete(heap); +} + +#[test] +fn huge_os_pages_api() { + // Unsupported on macOS: must fail gracefully, not crash. + let r = mi::reserve_huge_os_pages_at(1, 0); + if cfg!(target_os = "linux") { + // may succeed or fail depending on hugetlb pool; both are valid + let _ = r; + } else { + assert!(r.is_none()); + } +} + +static DEFERRED_CALLS: AtomicUsize = AtomicUsize::new(0); +static ERROR_CALLS: AtomicUsize = AtomicUsize::new(0); +static OUTPUT_BYTES: AtomicUsize = AtomicUsize::new(0); + +extern "C" fn on_deferred(_force: bool, _heartbeat: u64, _arg: *mut core::ffi::c_void) { + DEFERRED_CALLS.fetch_add(1, Ordering::Relaxed); +} +extern "C" fn on_error(_code: i32, _arg: *mut core::ffi::c_void) { + ERROR_CALLS.fetch_add(1, Ordering::Relaxed); +} +extern "C" fn on_output(_msg: *const u8, len: usize, _arg: *mut core::ffi::c_void) { + OUTPUT_BYTES.fetch_add(len, Ordering::Relaxed); +} + +#[test] +fn registered_hooks_fire() { + mi::register_deferred_free(Some(on_deferred), std::ptr::null_mut()); + mi::register_output(Some(on_output), std::ptr::null_mut()); + mi::register_error(Some(on_error), std::ptr::null_mut()); + + // deferred-free fires on collect + mi::collect(false); + assert!(DEFERRED_CALLS.load(Ordering::Relaxed) >= 1); + + // an oversized allocation reports through output+error hooks + let p = mi::malloc(isize::MAX as usize + 1); + assert!(p.is_null()); + assert!(ERROR_CALLS.load(Ordering::Relaxed) >= 1); + assert!(OUTPUT_BYTES.load(Ordering::Relaxed) > 0); + + mi::register_deferred_free(None, std::ptr::null_mut()); + mi::register_output(None, std::ptr::null_mut()); + mi::register_error(None, std::ptr::null_mut()); +} + +#[test] +fn exclusive_arena_never_escapes_to_os() { + // A 64 MiB exclusive arena holds exactly 2 segment blocks. A heap bound + // to it must (a) serve multi-block huge segments from the arena (sizes + // rounded up to whole blocks) and (b) return null once the arena is + // exhausted, never falling back to OS memory: this regressed into an + // infinite segment-allocation loop before segment_os_alloc refused the + // OS fallback for bound heaps. + const MIB: usize = 1024 * 1024; + let arena = mi::reserve_os_memory(64 * MIB, true, true).expect("arena"); + let heap = mi::heap_new_in_arena(arena).expect("heap"); + + let big = mi::heap_malloc(heap, 40 * MIB); // 2-block arena claim + assert!(!big.is_null() && mi::is_in_heap_region(big)); + unsafe { big.write_bytes(1, 64) }; + mi::free(big); + + let pinned: Vec<_> = (0..6).map(|_| mi::heap_malloc(heap, 10 * MIB)).collect(); + assert!( + pinned + .iter() + .all(|&p| !p.is_null() && mi::is_in_heap_region(p)) + ); + + // arena exhausted: large and huge requests fail instead of escaping + assert!(mi::heap_malloc(heap, 10 * MIB).is_null()); + assert!(mi::heap_malloc(heap, 40 * MIB).is_null()); + + for p in pinned { + mi::free(p); + } + // blocks returned: the arena serves the bound heap again + let p = mi::heap_malloc(heap, 10 * MIB); + assert!(!p.is_null() && mi::is_in_heap_region(p)); + mi::free(p); + mi::heap_delete(heap); +} diff --git a/vendor/rimalloc/tests/secure.rs b/vendor/rimalloc/tests/secure.rs new file mode 100644 index 00000000..27015f92 --- /dev/null +++ b/vendor/rimalloc/tests/secure.rs @@ -0,0 +1,44 @@ +//! Tests for the `secure` feature (encoded free lists, double-free detection). +#![cfg(feature = "secure")] + +use rimalloc as mi; + +#[test] +fn alloc_free_roundtrip_encoded() { + // basic correctness with encoding active across many size classes + let mut ptrs = Vec::new(); + for i in 0..2000usize { + let size = (i % 1500) + 1; + let p = mi::malloc(size); + assert!(!p.is_null()); + unsafe { std::ptr::write_bytes(p, 0x7e, size) }; + ptrs.push((p, size)); + } + for (p, size) in ptrs { + unsafe { + for j in 0..size { + assert_eq!(*p.add(j), 0x7e); + } + } + mi::free(p); + } +} + +#[test] +fn double_free_detected() { + // A double free must be detected (the block's first word decodes to a + // valid free-list link) and ignored rather than corrupting the list. + let p = mi::malloc(64); + let q = mi::malloc(64); // keep the page alive and non-empty + mi::free(p); + mi::free(p); // double free: caught by the secure check + // the page free list stays sound: we can keep allocating + let r = mi::malloc(64); + assert!(!r.is_null()); + let s = mi::malloc(64); + assert!(!s.is_null()); + assert_ne!(r, s); + mi::free(q); + mi::free(r); + mi::free(s); +} diff --git a/vendor/rimalloc/tests/target_segments.rs b/vendor/rimalloc/tests/target_segments.rs new file mode 100644 index 00000000..158828a6 --- /dev/null +++ b/vendor/rimalloc/tests/target_segments.rs @@ -0,0 +1,61 @@ +//! `MIMALLOC_TARGET_SEGMENTS_PER_THREAD` makes a thread shed segments past +//! its target so other threads can reclaim them (C's +//! `mi_segments_try_abandon` / `_mi_page_force_abandon`). Own-process test: +//! the option latches on first allocator use. + +use std::sync::mpsc; + +#[test] +fn target_segments_sheds_and_reuses() { + // SAFETY: single test in this binary; no concurrent env access. + unsafe { + std::env::set_var("MIMALLOC_TARGET_SEGMENTS_PER_THREAD", "2"); + std::env::set_var("MIMALLOC_PURGE_DELAY", "0"); + } + + // Build a working set large enough to need several segments, keeping + // pages full (so they land in the full bin, the abandon candidate set), + // then keep allocating: the target trigger must abandon excess segments + // rather than growing unboundedly. We assert it stays alive and the + // freed memory round-trips through abandonment. + // Miri interprets every allocation, so scale the working set down there + // (the abandon/reclaim code paths still run, just over fewer blocks). + let (n, keep, threads_n) = if cfg!(miri) { + (200usize, 180usize, 400usize) + } else { + (4000, 3900, 8000) + }; + let mut live: Vec<*mut u8> = Vec::new(); + for round in 0..8 { + for _ in 0..n { + let p = rimalloc::malloc(4096); + assert!(!p.is_null()); + unsafe { p.write_bytes(round as u8, 64) }; + live.push(p); + } + // Free most, retain a few per round to keep churn realistic. + for p in live.drain(..keep) { + rimalloc::free(p); + } + rimalloc::collect(false); + } + for p in live.drain(..) { + rimalloc::free(p); + } + + // A second thread must be able to reclaim what the first abandoned. + let (tx, rx) = mpsc::channel::(); + let h = std::thread::spawn(move || { + let mut ps: Vec<*mut u8> = (0..threads_n).map(|_| rimalloc::malloc(4096)).collect(); + for &p in &ps { + assert!(!p.is_null()); + } + tx.send(ps.len()).unwrap(); + for p in ps.drain(..) { + rimalloc::free(p); + } + }); + assert_eq!(rx.recv().unwrap(), threads_n); + h.join().unwrap(); + rimalloc::collect(true); +} From 3fe7baf02d703f68559599368f9fc0cbef4279ab Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Mon, 15 Jun 2026 19:06:01 -0500 Subject: [PATCH 12/24] Rimalloc for non-windows targets only --- Cargo.lock | 20 + Cargo.toml | 2 +- crates/flowstate/Cargo.toml | 7 +- crates/flowstate/src/main.rs | 17 +- vendor/rimalloc/Cargo.toml | 23 - vendor/rimalloc/examples/global.rs | 37 - vendor/rimalloc/examples/smoke.rs | 102 -- vendor/rimalloc/examples/stress.rs | 173 -- vendor/rimalloc/src/alloc.rs | 61 - vendor/rimalloc/src/api.rs | 737 -------- vendor/rimalloc/src/arena.rs | 426 ----- vendor/rimalloc/src/bins.rs | 136 -- vendor/rimalloc/src/bitmap.rs | 233 --- vendor/rimalloc/src/constants.rs | 114 -- vendor/rimalloc/src/free.rs | 245 --- vendor/rimalloc/src/heap.rs | 326 ---- vendor/rimalloc/src/heap_ops.rs | 281 ---- vendor/rimalloc/src/hooks.rs | 127 -- vendor/rimalloc/src/init.rs | 624 ------- vendor/rimalloc/src/lib.rs | 95 -- vendor/rimalloc/src/options.rs | 246 --- vendor/rimalloc/src/os.rs | 842 ---------- vendor/rimalloc/src/page.rs | 410 ----- vendor/rimalloc/src/page_ops.rs | 610 ------- vendor/rimalloc/src/protocol.rs | 217 --- vendor/rimalloc/src/segment.rs | 1852 --------------------- vendor/rimalloc/src/stats.rs | 149 -- vendor/rimalloc/src/sync.rs | 30 - vendor/rimalloc/src/visit.rs | 139 -- vendor/rimalloc/tests/api.rs | 412 ----- vendor/rimalloc/tests/api_alignment.rs | 60 - vendor/rimalloc/tests/arena_purge.rs | 41 - vendor/rimalloc/tests/committed_stat.rs | 124 -- vendor/rimalloc/tests/coverage_gaps.rs | 680 -------- vendor/rimalloc/tests/debug_fill.rs | 35 - vendor/rimalloc/tests/fill.rs | 172 -- vendor/rimalloc/tests/fork.rs | 72 - vendor/rimalloc/tests/lean_conformance.rs | 94 -- vendor/rimalloc/tests/loom_arena.rs | 328 ---- vendor/rimalloc/tests/loom_protocol.rs | 215 --- vendor/rimalloc/tests/mt_stress.rs | 144 -- vendor/rimalloc/tests/no_arena_cache.rs | 54 - vendor/rimalloc/tests/options_env.rs | 26 - vendor/rimalloc/tests/parity.rs | 150 -- vendor/rimalloc/tests/secure.rs | 44 - vendor/rimalloc/tests/target_segments.rs | 61 - 46 files changed, 37 insertions(+), 10956 deletions(-) delete mode 100644 vendor/rimalloc/Cargo.toml delete mode 100644 vendor/rimalloc/examples/global.rs delete mode 100644 vendor/rimalloc/examples/smoke.rs delete mode 100644 vendor/rimalloc/examples/stress.rs delete mode 100644 vendor/rimalloc/src/alloc.rs delete mode 100644 vendor/rimalloc/src/api.rs delete mode 100644 vendor/rimalloc/src/arena.rs delete mode 100644 vendor/rimalloc/src/bins.rs delete mode 100644 vendor/rimalloc/src/bitmap.rs delete mode 100644 vendor/rimalloc/src/constants.rs delete mode 100644 vendor/rimalloc/src/free.rs delete mode 100644 vendor/rimalloc/src/heap.rs delete mode 100644 vendor/rimalloc/src/heap_ops.rs delete mode 100644 vendor/rimalloc/src/hooks.rs delete mode 100644 vendor/rimalloc/src/init.rs delete mode 100644 vendor/rimalloc/src/lib.rs delete mode 100644 vendor/rimalloc/src/options.rs delete mode 100644 vendor/rimalloc/src/os.rs delete mode 100644 vendor/rimalloc/src/page.rs delete mode 100644 vendor/rimalloc/src/page_ops.rs delete mode 100644 vendor/rimalloc/src/protocol.rs delete mode 100644 vendor/rimalloc/src/segment.rs delete mode 100644 vendor/rimalloc/src/stats.rs delete mode 100644 vendor/rimalloc/src/sync.rs delete mode 100644 vendor/rimalloc/src/visit.rs delete mode 100644 vendor/rimalloc/tests/api.rs delete mode 100644 vendor/rimalloc/tests/api_alignment.rs delete mode 100644 vendor/rimalloc/tests/arena_purge.rs delete mode 100644 vendor/rimalloc/tests/committed_stat.rs delete mode 100644 vendor/rimalloc/tests/coverage_gaps.rs delete mode 100644 vendor/rimalloc/tests/debug_fill.rs delete mode 100644 vendor/rimalloc/tests/fill.rs delete mode 100644 vendor/rimalloc/tests/fork.rs delete mode 100644 vendor/rimalloc/tests/lean_conformance.rs delete mode 100644 vendor/rimalloc/tests/loom_arena.rs delete mode 100644 vendor/rimalloc/tests/loom_protocol.rs delete mode 100644 vendor/rimalloc/tests/mt_stress.rs delete mode 100644 vendor/rimalloc/tests/no_arena_cache.rs delete mode 100644 vendor/rimalloc/tests/options_env.rs delete mode 100644 vendor/rimalloc/tests/parity.rs delete mode 100644 vendor/rimalloc/tests/secure.rs delete mode 100644 vendor/rimalloc/tests/target_segments.rs diff --git a/Cargo.lock b/Cargo.lock index 3ea46054..cc94e617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2670,6 +2670,7 @@ dependencies = [ "gpui-symbols", "hotpath", "memchr", + "mimalloc", "palette", "rimalloc", "rustc-hash 2.1.2", @@ -4948,6 +4949,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.17" @@ -5522,6 +5532,15 @@ dependencies = [ "paste", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -7485,6 +7504,7 @@ dependencies = [ [[package]] name = "rimalloc" version = "0.1.0" +source = "git+https://github.com/moggedhedien/rimalloc.git#6a5ba948803091b1a74c49c5872e86aa47c346fb" dependencies = [ "ctor 1.0.7", "libc", diff --git a/Cargo.toml b/Cargo.toml index 7e26a214..7cdeca66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ hotpath = "0.16.1" ignore = "0.4.25" imagesize = "0.13" lopdf = "0.40.0" -rimalloc = { path = "vendor/rimalloc" } +mimalloc = "0.1.52" mitex = "0.2" notify = "8.2.0" quick-xml = "0.36.2" diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 2163bc49..8a0503f3 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -22,7 +22,6 @@ gpui-component = { workspace = true } gpui-component-assets = { workspace = true } hotpath = { workspace = true } memchr = "2.8.1" -rimalloc = { workspace = true } palette = "0.7.6" rustc-hash = { workspace = true } serde = { workspace = true } @@ -56,3 +55,9 @@ gpui-symbols = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] cosmic-text = { workspace = true } + +[target.'cfg(not(windows))'.dependencies] +rimalloc = { git = "https://github.com/moggedhedien/rimalloc.git" } + +[target.'cfg(windows)'.dependencies] +mimalloc = { workspace = true } diff --git a/crates/flowstate/src/main.rs b/crates/flowstate/src/main.rs index a9bd1803..cf6d0e72 100644 --- a/crates/flowstate/src/main.rs +++ b/crates/flowstate/src/main.rs @@ -18,21 +18,24 @@ impl Default for FlowstateAllocator { } } -// SAFETY: This allocator delegates all allocation operations directly to -// rimalloc's `GlobalAlloc` implementation without changing the pointer, -// layout, or ownership contracts. unsafe impl GlobalAlloc for FlowstateAllocator { + #[cfg(not(windows))] unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - // SAFETY: The caller upholds `GlobalAlloc::alloc`'s layout contract, and - // the layout is forwarded unchanged to rimalloc. unsafe { rimalloc::Rimalloc.alloc(layout) } } + #[cfg(windows)] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + unsafe { mimalloc::MiMalloc.alloc(layout) } + } + #[cfg(not(windows))] unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - // SAFETY: The caller guarantees `ptr` and `layout` came from this - // allocator; both are forwarded unchanged to rimalloc. unsafe { rimalloc::Rimalloc.dealloc(ptr, layout) } } + #[cfg(windows)] + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { mimalloc::MiMalloc.dealloc(ptr, layout) } + } } #[cfg(not(feature = "hotpath-alloc"))] diff --git a/vendor/rimalloc/Cargo.toml b/vendor/rimalloc/Cargo.toml deleted file mode 100644 index 4ff9dbd4..00000000 --- a/vendor/rimalloc/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "rimalloc" -description = "A pure-Rust port of mimalloc: a compact, fast general-purpose allocator" -version = "0.1.0" -edition = "2024" -license = "MIT" -repository = "https://github.com/moggedhedien/rimalloc" - -[dependencies] -libc = { version = "0.2", default-features = false } -ctor = { version = "1.0.7", default-features = false, features = ["proc_macro", "std"] } - -[features] -default = [] -stats = [] -secure = [] -debug-fill = [] -nightly = [] - -[lints.rust] -unsafe_op_in_unsafe_fn = "deny" -missing_debug_implementations = "allow" -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)', 'cfg(fuzzing)'] } diff --git a/vendor/rimalloc/examples/global.rs b/vendor/rimalloc/examples/global.rs deleted file mode 100644 index 08b02ec9..00000000 --- a/vendor/rimalloc/examples/global.rs +++ /dev/null @@ -1,37 +0,0 @@ -use rimalloc::Rimalloc; - -#[global_allocator] -static ALLOC: Rimalloc = Rimalloc; - -fn main() { - // exercise Vec/String/HashMap through the global allocator - let mut v: Vec = Vec::new(); - for i in 0..1_000_000 { - v.push(i); - } - assert_eq!(v.iter().sum::(), 999_999 * 1_000_000 / 2); - - let mut m = std::collections::HashMap::new(); - for i in 0..100_000u64 { - m.insert(i, i.to_string()); - } - assert_eq!(m[&99_999], "99999"); - drop(m); - - let handles: Vec<_> = (0..8) - .map(|t| { - std::thread::spawn(move || { - let mut buf = Vec::new(); - for i in 0..50_000usize { - buf.push(vec![t as u8; (i % 512) + 1]); - if i % 3 == 0 { - buf.swap_remove(buf.len() / 2); - } - } - buf.len() - }) - }) - .collect(); - let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum(); - println!("global allocator OK ({total} survivors)"); -} diff --git a/vendor/rimalloc/examples/smoke.rs b/vendor/rimalloc/examples/smoke.rs deleted file mode 100644 index b953572f..00000000 --- a/vendor/rimalloc/examples/smoke.rs +++ /dev/null @@ -1,102 +0,0 @@ -fn main() { - // basic alloc/free - let p = rimalloc::malloc(42); - assert!(!p.is_null()); - assert!(rimalloc::usable_size(p) >= 42); - unsafe { std::ptr::write_bytes(p, 0xab, 42) }; - rimalloc::free(p); - - // many sizes - let mut ptrs = Vec::new(); - for i in 0..10000usize { - let size = (i % 4000) + 1; - let p = rimalloc::malloc(size); - assert!(!p.is_null(), "alloc {size}"); - unsafe { std::ptr::write_bytes(p, (i & 0xff) as u8, size) }; - ptrs.push((p, size, (i & 0xff) as u8)); - } - for (p, size, v) in &ptrs { - unsafe { - for j in 0..*size { - assert_eq!(*p.add(j), *v, "corruption at {j} of {size}"); - } - } - rimalloc::free(*p); - } - - // zalloc - let p = rimalloc::zalloc(1024 * 1024); - unsafe { - for j in 0..1024 * 1024 { - assert_eq!(*p.add(j), 0); - } - } - rimalloc::free(p); - - // large + huge - for size in [100_000, 1_000_000, 20_000_000, 40_000_000] { - let p = rimalloc::malloc(size); - assert!(!p.is_null()); - unsafe { - *p = 1; - *p.add(size - 1) = 2; - } - rimalloc::free(p); - } - - // aligned - for align in [16usize, 64, 256, 4096, 65536, 1 << 20] { - let p = rimalloc::malloc_aligned(1000, align); - assert!(!p.is_null()); - assert_eq!(p.addr() % align, 0, "align {align}"); - unsafe { std::ptr::write_bytes(p, 0xcd, 1000) }; - assert!(rimalloc::usable_size(p) >= 1000); - rimalloc::free(p); - } - - // realloc - let mut p = rimalloc::malloc(10); - unsafe { std::ptr::write_bytes(p, 7, 10) }; - p = rimalloc::realloc(p, 100_000); - unsafe { - for j in 0..10 { - assert_eq!(*p.add(j), 7); - } - } - rimalloc::free(p); - - // cross-thread free - let mut handles = Vec::new(); - let (tx, rx) = std::sync::mpsc::channel::<(usize, usize)>(); - for _ in 0..4 { - let tx = tx.clone(); - handles.push(std::thread::spawn(move || { - for i in 0..1000usize { - let size = (i % 2048) + 8; - let p = rimalloc::malloc(size); - unsafe { std::ptr::write_bytes(p, 0x5a, size) }; - tx.send((p.addr(), size)).unwrap(); - } - })); - } - drop(tx); - let mut received = Vec::new(); - while let Ok((addr, size)) = rx.recv() { - received.push((addr, size)); - } - for h in handles { - h.join().unwrap(); - } - for (addr, size) in received { - let p = std::ptr::with_exposed_provenance_mut::(addr); - unsafe { - for j in 0..size { - assert_eq!(*p.add(j), 0x5a); - } - } - rimalloc::free(p); - } - rimalloc::collect(true); - rimalloc::stats_print(); - println!("rimalloc smoke: OK"); -} diff --git a/vendor/rimalloc/examples/stress.rs b/vendor/rimalloc/examples/stress.rs deleted file mode 100644 index f08c8509..00000000 --- a/vendor/rimalloc/examples/stress.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Faithful port of mimalloc `test/test-stress.c`: multiple threads -//! allocating linearly-distributed power-of-two sizes, transferring blocks -//! between threads, with threads destroyed and recreated each iteration. -//! -//! Usage: stress [THREADS] [SCALE] [ITER] - -use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; - -const TRANSFERS: usize = 1000; -const COOKIE: usize = 0xbf58476d1ce4e5b9; - -static TRANSFER: [AtomicPtr; TRANSFERS] = - [const { AtomicPtr::new(std::ptr::null_mut()) }; TRANSFERS]; - -static ALLOW_LARGE_OBJECTS: bool = false; - -/// splitmix64 (matching the C test) -fn pick(r: &mut usize) -> usize { - let mut x = *r; - x ^= x >> 30; - x = x.wrapping_mul(0xbf58476d1ce4e5b9); - x ^= x >> 27; - x = x.wrapping_mul(0x94d049bb133111eb); - x ^= x >> 31; - *r = x; - x -} - -fn chance(perc: usize, r: &mut usize) -> bool { - pick(r) % 100 <= perc -} - -fn alloc_items(mut items: usize, r: &mut usize) -> *mut usize { - if chance(1, r) { - if chance(1, r) && ALLOW_LARGE_OBJECTS { - items *= 10000; // 0.01% giant - } else if chance(10, r) && ALLOW_LARGE_OBJECTS { - items *= 1000; // 0.1% huge - } else { - items *= 100; // 1% large objects - } - } - if (32..=40).contains(&items) { - items *= 2; // pthreads uses 320b allocations - } - if items == 0 { - items = 1; - } - let p = rimalloc::calloc(items, size_of::()).cast::(); - if !p.is_null() { - for i in 0..items { - unsafe { - // C builds the reference harness with NDEBUG, compiling out - // its `assert(p[i] == 0)`; keep instruction parity here. - debug_assert_eq!(*p.add(i), 0); - *p.add(i) = (items - i) ^ COOKIE; - } - } - } - p -} - -fn free_items(p: *mut usize) { - if !p.is_null() { - unsafe { - let items = *p ^ COOKIE; - for i in 0..items { - assert_eq!( - *p.add(i) ^ COOKIE, - items - i, - "memory corruption at block {p:p} at {i}" - ); - } - } - } - rimalloc::free(p.cast()); -} - -fn stress(tid: usize, scale: usize) { - let mut r: usize = (tid + 1) * 43; - const MAX_ITEM_SHIFT: usize = 5; // 128 - const MAX_ITEM_RETAINED_SHIFT: usize = MAX_ITEM_SHIFT + 2; - let mut allocs = 100 * scale * (tid % 8 + 1); // some threads do more - let mut retain = allocs / 2; - let mut data: Vec<*mut usize> = Vec::new(); - let mut retained: Vec<*mut usize> = Vec::with_capacity(retain); - - while allocs > 0 || retain > 0 { - if retain == 0 || (chance(50, &mut r) && allocs > 0) { - // 50%+ alloc - allocs -= 1; - data.push(alloc_items(1 << (pick(&mut r) % MAX_ITEM_SHIFT), &mut r)); - } else { - // 25% retain - retained.push(alloc_items( - 1 << (pick(&mut r) % MAX_ITEM_RETAINED_SHIFT), - &mut r, - )); - retain -= 1; - } - if chance(66, &mut r) && !data.is_empty() { - // 66% free previous alloc - let idx = pick(&mut r) % data.len(); - free_items(data[idx]); - data[idx] = std::ptr::null_mut(); - } - if chance(25, &mut r) && !data.is_empty() { - // 25% exchange a local pointer with the (shared) transfer buffer - let data_idx = pick(&mut r) % data.len(); - let transfer_idx = pick(&mut r) % TRANSFERS; - let p = data[data_idx]; - let q = TRANSFER[transfer_idx].swap(p, Ordering::AcqRel); - data[data_idx] = q; - } - } - // free everything that is left - for p in retained { - free_items(p); - } - for p in data { - free_items(p); - } -} - -static SCALE: AtomicUsize = AtomicUsize::new(0); - -fn run_os_threads(nthreads: usize, scale: usize) { - SCALE.store(scale, Ordering::Relaxed); - let scale = SCALE.load(Ordering::Relaxed); - std::thread::scope(|s| { - for tid in 0..nthreads { - s.spawn(move || { - stress(tid, scale); - }); - } - }); -} - -fn main() { - let args: Vec = std::env::args().collect(); - let threads: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(32); - let scale: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(50); - let iter: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(50); - println!("stress: {threads} threads, scale {scale}, {iter} iterations"); - - let start = std::time::Instant::now(); - let mut r: usize = 42; - for n in 0..iter { - run_os_threads(threads, scale); - for t in &TRANSFER { - if chance(50, &mut r) || n + 1 == iter { - // free all on last run, otherwise free half - let p = t.swap(std::ptr::null_mut(), Ordering::AcqRel); - free_items(p); - } - } - if (n + 1) % 10 == 0 { - println!("- iterations left: {:3}", iter - (n + 1)); - } - } - rimalloc::collect(true); - println!( - "stress test completed successfully in {:.2?}", - start.elapsed() - ); - let [mmap, munmap, madvise, mprotect] = rimalloc::os_syscall_counts(); - println!("syscalls: mmap={mmap} munmap={munmap} madvise={madvise} mprotect={mprotect}"); - let (abandoned, tries, hits) = rimalloc::abandoned_stats(); - println!("abandoned={abandoned} reclaim_tries={tries} reclaim_hits={hits}"); - if std::env::var_os("STRESS_STATS").is_some() { - rimalloc::stats_print(); - } -} diff --git a/vendor/rimalloc/src/alloc.rs b/vendor/rimalloc/src/alloc.rs deleted file mode 100644 index c3d37f1c..00000000 --- a/vendor/rimalloc/src/alloc.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Allocation fast paths (port of `alloc.c`). - -use core::ptr::{self, NonNull}; - -use crate::constants::*; -use crate::heap::{Heap, heap_get_free_small_page}; -use crate::page::Block; -use crate::page::Page; -use crate::page_ops::malloc_generic; - -/// `_mi_page_malloc_zero`: pop a block from the page free list, or fall -/// into the generic path. ~7 instructions in the fast case. -#[inline(always)] -pub fn page_malloc_zero(heap: &Heap, page: &Page, size: usize, zero: bool) -> Option> { - debug_assert!(page.block_size.get() == 0 || page.block_size() >= size); - let block = page.free.get(); - if block.is_null() { - return malloc_generic(heap, size, zero, 0); - } - // SAFETY: free-list nodes are live blocks of this page. - page.free.set(unsafe { page.block_next(block) }); - page.used.set(page.used.get() + 1); - debug_assert!( - page.block_size() < MAX_ALIGN_SIZE || block.addr().is_multiple_of(MAX_ALIGN_SIZE) - ); - - if zero { - debug_assert!(page.block_size.get() != 0); - if page.free_is_zero.get() { - // SAFETY: the block is exclusively ours; clear the list word. - unsafe { Block::write_next(block, ptr::null_mut()) }; - } else { - // SAFETY: the block is block_size bytes, exclusively ours now. - unsafe { ptr::write_bytes(block.cast::(), 0, page.block_size()) }; - } - } else if cfg!(feature = "debug-fill") && !page.is_huge.get() { - // MI_DEBUG_UNINIT: stamp fresh blocks to surface uninitialized reads. - // SAFETY: the block is exclusively ours. - unsafe { ptr::write_bytes(block.cast::(), 0xD0, page.usable_block_size()) }; - } - NonNull::new(block.cast()) -} - -/// `mi_heap_malloc_small_zero` -#[inline(always)] -pub fn heap_malloc_small_zero(heap: &Heap, size: usize, zero: bool) -> Option> { - debug_assert!(size <= SMALL_SIZE_MAX); - // SAFETY: pages_free_direct entries are live pages or the static empty page. - let page = unsafe { &*heap_get_free_small_page(heap, size + PADDING_SIZE) }; - page_malloc_zero(heap, page, size + PADDING_SIZE, zero) -} - -/// `_mi_heap_malloc_zero_ex`: the main entry point. -#[inline(always)] -pub fn heap_malloc_zero(heap: &Heap, size: usize, zero: bool) -> Option> { - if size <= SMALL_SIZE_MAX { - heap_malloc_small_zero(heap, size, zero) - } else { - malloc_generic(heap, size + PADDING_SIZE, zero, 0) - } -} diff --git a/vendor/rimalloc/src/api.rs b/vendor/rimalloc/src/api.rs deleted file mode 100644 index ec889492..00000000 --- a/vendor/rimalloc/src/api.rs +++ /dev/null @@ -1,737 +0,0 @@ -//! Public allocation API, mirroring `mimalloc.h` (`mi_*` functions) plus a -//! `GlobalAlloc` implementation. - -use core::alloc::{GlobalAlloc, Layout}; -use core::ptr::{self, NonNull}; - -use crate::alloc::heap_malloc_zero; -use crate::constants::*; -use crate::heap::Heap; -use crate::init::heap_default_unchecked; -use crate::page_ops::malloc_generic; -use crate::segment::ptr_segment; - -#[inline(always)] -fn as_ptr(p: Option>) -> *mut u8 { - p.map_or(ptr::null_mut(), NonNull::as_ptr) -} - -// --------------------------------------------------------------------------- -// malloc / zalloc / calloc / free -// --------------------------------------------------------------------------- - -/// `mi_malloc` -#[inline] -pub fn malloc(size: usize) -> *mut u8 { - as_ptr(heap_malloc_zero(heap_default_unchecked(), size, false)) -} - -/// `mi_zalloc` -#[inline] -pub fn zalloc(size: usize) -> *mut u8 { - as_ptr(heap_malloc_zero(heap_default_unchecked(), size, true)) -} - -/// `mi_malloc_small` (requires `size <= SMALL_SIZE_MAX`) -#[inline] -pub fn malloc_small(size: usize) -> *mut u8 { - debug_assert!(size <= SMALL_SIZE_MAX); - as_ptr(crate::alloc::heap_malloc_small_zero( - heap_default_unchecked(), - size, - false, - )) -} - -/// `mi_count_size_overflow`-checked multiply. -#[inline] -fn count_size(count: usize, size: usize) -> Option { - count.checked_mul(size).filter(|&t| t <= MAX_ALLOC_SIZE) -} - -/// `mi_calloc` -#[inline] -pub fn calloc(count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => zalloc(total), - None => ptr::null_mut(), - } -} - -/// `mi_mallocn` -#[inline] -pub fn mallocn(count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => malloc(total), - None => ptr::null_mut(), - } -} - -/// `mi_free` -#[inline] -pub fn free(p: *mut u8) { - crate::free::free(p) -} - -/// `mi_usable_size` -#[inline] -pub fn usable_size(p: *const u8) -> usize { - crate::free::usable_size(p) -} - -/// `mi_good_size` -#[inline] -pub fn good_size(size: usize) -> usize { - crate::bins::good_size(size) -} - -// --------------------------------------------------------------------------- -// realloc family -// --------------------------------------------------------------------------- - -/// Common tail of the realloc paths: copy into the new block (zeroing the -/// growth tail first), free the old block. -fn realloc_move(p: *mut u8, newp: *mut u8, oldsize: usize, newsize: usize, zero: bool) { - if zero && newsize > oldsize { - let start = oldsize.saturating_sub(size_of::()); - // SAFETY: newp has at least newsize usable bytes. - unsafe { ptr::write_bytes(newp.add(start), 0, newsize - start) }; - } - if newsize > 0 { - // SAFETY: disjoint allocations, both at least min(old,new) long. - unsafe { ptr::copy_nonoverlapping(p, newp, oldsize.min(newsize)) }; - } - free(p); -} - -/// `_mi_heap_realloc_zero` -fn heap_realloc_zero(heap: &Heap, p: *mut u8, newsize: usize, zero: bool) -> *mut u8 { - if p.is_null() { - return as_ptr(heap_malloc_zero(heap, newsize, zero)); - } - let size = usable_size(p); - // reallocation still fits and not more than 50% waste? - if newsize <= size && newsize >= size / 2 && newsize > 0 { - return p; - } - let newp = as_ptr(heap_malloc_zero(heap, newsize, false)); - if !newp.is_null() { - if newsize == 0 { - // applications expect zero-reallocation to be zeroed (C issue #725). - // SAFETY: a zero-size block still has a usable first byte. - unsafe { newp.write(0) }; - } - realloc_move(p, newp, size, newsize, zero); - } - newp -} - -/// `mi_realloc` -#[inline] -pub fn realloc(p: *mut u8, newsize: usize) -> *mut u8 { - heap_realloc_zero(heap_default_unchecked(), p, newsize, false) -} - -/// `mi_reallocn` -#[inline] -pub fn reallocn(p: *mut u8, count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => realloc(p, total), - None => ptr::null_mut(), - } -} - -/// `mi_rezalloc` -#[inline] -pub fn rezalloc(p: *mut u8, newsize: usize) -> *mut u8 { - heap_realloc_zero(heap_default_unchecked(), p, newsize, true) -} - -/// `mi_recalloc` -#[inline] -pub fn recalloc(p: *mut u8, count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => rezalloc(p, total), - None => ptr::null_mut(), - } -} - -/// `mi_reallocf`: free `p` on failure. -#[inline] -pub fn reallocf(p: *mut u8, newsize: usize) -> *mut u8 { - let newp = realloc(p, newsize); - if newp.is_null() && !p.is_null() { - free(p); - } - newp -} - -/// `mi_expand`: in-place only. -#[inline] -pub fn expand(p: *mut u8, newsize: usize) -> *mut u8 { - if p.is_null() { - return ptr::null_mut(); - } - if newsize > usable_size(p) { - ptr::null_mut() - } else { - p - } -} - -// --------------------------------------------------------------------------- -// Aligned allocation -// --------------------------------------------------------------------------- - -/// `mi_malloc_is_naturally_aligned` -fn is_naturally_aligned(size: usize, alignment: usize) -> bool { - debug_assert!(is_power_of_two(alignment)); - if alignment > size { - return false; - } - if alignment <= MAX_ALIGN_SIZE { - return true; - } - let bsize = good_size(size); - bsize <= MAX_ALIGN_GUARANTEE && bsize & (alignment - 1) == 0 -} - -/// `mi_heap_malloc_zero_aligned_at_overalloc` -fn malloc_aligned_overalloc( - heap: &Heap, - size: usize, - alignment: usize, - offset: usize, - zero: bool, -) -> *mut u8 { - let p = if alignment > BLOCK_ALIGNMENT_MAX { - // dedicated huge segment, aligned within the single page - if offset != 0 { - crate::error("aligned allocation with very large alignment cannot use an offset"); - return ptr::null_mut(); - } - let oversize = if size <= SMALL_SIZE_MAX { - SMALL_SIZE_MAX + 1 - } else { - size - }; - as_ptr(malloc_generic(heap, oversize, false, alignment)) - } else { - let oversize = size.max(MAX_ALIGN_SIZE) + alignment - 1; - as_ptr(heap_malloc_zero(heap, oversize, zero)) - }; - if p.is_null() { - return ptr::null_mut(); - } - // SAFETY: p was just allocated from a live page. - let page = unsafe { crate::segment::ptr_page(p) }; - let align_mask = alignment - 1; - let poffset = (p.addr() + offset) & align_mask; - let adjust = if poffset == 0 { 0 } else { alignment - poffset }; - // SAFETY: adjust < alignment <= usable slack of the oversized block. - let aligned_p = unsafe { p.add(adjust) }; - if aligned_p != p { - page.set_has_aligned(true); - } - debug_assert!((aligned_p.addr() + offset).is_multiple_of(alignment)); - if alignment > BLOCK_ALIGNMENT_MAX && zero { - let usable = crate::free::usable_size(aligned_p); - // SAFETY: aligned_p..+usable is within the block. - unsafe { ptr::write_bytes(aligned_p, 0, usable) }; - } - aligned_p -} - -/// `mi_heap_malloc_zero_aligned_at` -fn malloc_zero_aligned_at( - heap: &Heap, - size: usize, - alignment: usize, - offset: usize, - zero: bool, -) -> *mut u8 { - if alignment == 0 || !is_power_of_two(alignment) { - return ptr::null_mut(); - } - if size > MAX_ALLOC_SIZE { - return ptr::null_mut(); - } - // Fast path: a small block with the right alignment. - if offset == 0 && alignment <= size && size <= SMALL_SIZE_MAX { - // SAFETY: pages_free_direct entries are live or the static empty page. - let page = unsafe { &*crate::heap::heap_get_free_small_page(heap, size + PADDING_SIZE) }; - let head = page.free.get(); - if !head.is_null() && head.addr() & (alignment - 1) == 0 { - return as_ptr(crate::alloc::page_malloc_zero( - heap, - page, - size + PADDING_SIZE, - zero, - )); - } - } - if offset == 0 && is_naturally_aligned(size, alignment) { - let p = as_ptr(heap_malloc_zero(heap, size, zero)); - debug_assert!(p.is_null() || p.addr().is_multiple_of(alignment)); - if p.addr() & (alignment - 1) == 0 { - return p; - } - free(p); - } - malloc_aligned_overalloc(heap, size, alignment, offset, zero) -} - -/// `mi_malloc_aligned` -#[inline] -pub fn malloc_aligned(size: usize, alignment: usize) -> *mut u8 { - malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, false) -} - -/// `mi_malloc_aligned_at` -#[inline] -pub fn malloc_aligned_at(size: usize, alignment: usize, offset: usize) -> *mut u8 { - malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, offset, false) -} - -/// `mi_zalloc_aligned` -#[inline] -pub fn zalloc_aligned(size: usize, alignment: usize) -> *mut u8 { - malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, true) -} - -/// `mi_zalloc_aligned_at` -#[inline] -pub fn zalloc_aligned_at(size: usize, alignment: usize, offset: usize) -> *mut u8 { - malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, offset, true) -} - -/// `mi_calloc_aligned` -#[inline] -pub fn calloc_aligned(count: usize, size: usize, alignment: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => zalloc_aligned(total, alignment), - None => ptr::null_mut(), - } -} - -/// `mi_calloc_aligned_at` -#[inline] -pub fn calloc_aligned_at(count: usize, size: usize, alignment: usize, offset: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => zalloc_aligned_at(total, alignment, offset), - None => ptr::null_mut(), - } -} - -fn realloc_zero_aligned_at( - p: *mut u8, - newsize: usize, - alignment: usize, - offset: usize, - zero: bool, -) -> *mut u8 { - if !is_power_of_two(alignment) { - return ptr::null_mut(); - } - if p.is_null() { - return malloc_zero_aligned_at(heap_default_unchecked(), newsize, alignment, offset, zero); - } - let size = usable_size(p); - // still fits, aligned, and not more than ~25% waste (matches C)? - if newsize <= size - && newsize >= size - size / 2 - && (p.addr() + offset).is_multiple_of(alignment) - { - return p; - } - let newp = malloc_zero_aligned_at(heap_default_unchecked(), newsize, alignment, offset, zero); - if !newp.is_null() { - if newsize == 0 { - // SAFETY: a zero-size block still has a usable first byte. - unsafe { newp.write(0) }; - } - realloc_move(p, newp, size, newsize, zero); - } - newp -} - -/// `mi_realloc_aligned` -#[inline] -pub fn realloc_aligned(p: *mut u8, newsize: usize, alignment: usize) -> *mut u8 { - realloc_zero_aligned_at(p, newsize, alignment, 0, false) -} - -/// `mi_realloc_aligned_at` -#[inline] -pub fn realloc_aligned_at(p: *mut u8, newsize: usize, alignment: usize, offset: usize) -> *mut u8 { - realloc_zero_aligned_at(p, newsize, alignment, offset, false) -} - -/// `mi_rezalloc_aligned` -#[inline] -pub fn rezalloc_aligned(p: *mut u8, newsize: usize, alignment: usize) -> *mut u8 { - realloc_zero_aligned_at(p, newsize, alignment, 0, true) -} - -/// `mi_recalloc_aligned` -#[inline] -pub fn recalloc_aligned(p: *mut u8, count: usize, size: usize, alignment: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => rezalloc_aligned(p, total, alignment), - None => ptr::null_mut(), - } -} - -// --------------------------------------------------------------------------- -// Heap API -// --------------------------------------------------------------------------- - -/// `mi_heap_new` -pub fn heap_new() -> Option<&'static Heap> { - crate::heap_ops::heap_new(true, 0, crate::arena::ARENA_ID_NONE) -} - -/// `mi_heap_new_ex`: custom tag, destroy permission, and arena binding. -pub fn heap_new_ex(tag: u8, allow_destroy: bool, arena_id: usize) -> Option<&'static Heap> { - crate::heap_ops::heap_new(allow_destroy, tag, arena_id) -} - -/// `mi_heap_new_in_arena`: a heap allocating exclusively from `arena_id`. -pub fn heap_new_in_arena(arena_id: usize) -> Option<&'static Heap> { - crate::heap_ops::heap_new(false, 0, arena_id) -} - -/// `mi_reserve_os_memory_ex`: pre-reserve OS memory as an arena. Returns -/// the arena id (use with [`heap_new_in_arena`] when `exclusive`). -pub fn reserve_os_memory(size: usize, commit: bool, exclusive: bool) -> Option { - crate::arena::reserve_os_memory(size, commit, exclusive) -} - -/// `mi_manage_os_memory_ex`: adopt caller-provided memory as an arena -/// (never freed by rimalloc). -/// -/// # Safety -/// See [`crate::arena::manage_os_memory`]. -pub unsafe fn manage_os_memory( - start: *mut u8, - size: usize, - is_committed: bool, - is_zero: bool, - numa_node: i32, - exclusive: bool, -) -> Option { - // SAFETY: per contract. - unsafe { - crate::arena::manage_os_memory(start, size, is_committed, is_zero, numa_node, exclusive) - } -} - -/// `mi_reserve_huge_os_pages_at`: reserve `pages` 1GiB huge OS pages as an -/// arena on `numa_node`. Returns the arena id, or `None` where huge OS -/// pages are unsupported (e.g. macOS arm64). -pub fn reserve_huge_os_pages_at(pages: usize, numa_node: i32) -> Option { - crate::os::alloc_huge_os_pages(pages).and_then(|(start, mem)| { - // SAFETY: fresh exclusive mapping from the OS. - unsafe { crate::arena::manage_os_memory(start, mem.size, true, true, numa_node, false) } - }) -} - -/// `mi_heap_get_default` -pub fn heap_get_default() -> &'static Heap { - crate::init::heap_default() -} - -/// `mi_heap_get_backing` -pub fn heap_get_backing() -> &'static Heap { - let heap = heap_get_default(); - // SAFETY: tld and backing heap live for the thread lifetime. - unsafe { &*(*heap.tld.get()).heap_backing.get() } -} - -/// `mi_heap_set_default` -pub fn heap_set_default(heap: &Heap) -> &'static Heap { - crate::init::heap_set_default(heap) -} - -/// `mi_heap_delete` -pub fn heap_delete(heap: &Heap) { - crate::heap_ops::heap_delete(heap) -} - -/// `mi_heap_destroy` -pub fn heap_destroy(heap: &Heap) { - crate::heap_ops::heap_destroy(heap) -} - -/// `mi_heap_malloc` -#[inline] -pub fn heap_malloc(heap: &Heap, size: usize) -> *mut u8 { - as_ptr(heap_malloc_zero(heap, size, false)) -} - -/// `mi_heap_zalloc` -#[inline] -pub fn heap_zalloc(heap: &Heap, size: usize) -> *mut u8 { - as_ptr(heap_malloc_zero(heap, size, true)) -} - -/// `mi_heap_calloc` -#[inline] -pub fn heap_calloc(heap: &Heap, count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => heap_zalloc(heap, total), - None => ptr::null_mut(), - } -} - -/// `mi_heap_mallocn` -#[inline] -pub fn heap_mallocn(heap: &Heap, count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => heap_malloc(heap, total), - None => ptr::null_mut(), - } -} - -/// `mi_heap_realloc` -#[inline] -pub fn heap_realloc(heap: &Heap, p: *mut u8, newsize: usize) -> *mut u8 { - heap_realloc_zero(heap, p, newsize, false) -} - -/// `mi_heap_malloc_aligned` -#[inline] -pub fn heap_malloc_aligned(heap: &Heap, size: usize, alignment: usize) -> *mut u8 { - malloc_zero_aligned_at(heap, size, alignment, 0, false) -} - -/// `mi_collect` -pub fn collect(force: bool) { - crate::heap_ops::heap_collect(heap_get_default(), force); - crate::arena::try_purge(force); - if force { - crate::os::cache_collect(); - } - crate::os::drain_deferred(); -} - -/// `mi_thread_init` -pub fn thread_init() { - crate::init::thread_init(); -} - -/// `mi_thread_done` -pub fn thread_done() { - crate::init::thread_done(); -} - -// --------------------------------------------------------------------------- -// Introspection -// --------------------------------------------------------------------------- - -/// `mi_is_in_heap_region` (approximation: do we own the containing segment). -/// -/// # Safety -/// `p` must be null or a pointer into memory obtained from this allocator. -pub unsafe fn check_owned(p: *const u8) -> bool { - if p.is_null() || p.addr() & (PTR_SIZE - 1) != 0 { - return false; - } - if crate::arena::contains(p) { - return true; - } - // SAFETY: only reads the segment header derived from the address; a - // wrong address may fault (debug-only helper). - let segment = unsafe { ptr_segment(p) }; - segment.cookie.get() == segment.as_ptr().addr() ^ 0xa5a5_5a5a_1234_5678 -} - -/// `mi_is_in_heap_region`: true if `p` lies inside rimalloc-managed memory -/// (arena reservations); always safe to call. -pub fn is_in_heap_region(p: *const u8) -> bool { - !p.is_null() && crate::arena::contains(p) -} - -// --------------------------------------------------------------------------- -// GlobalAlloc -// --------------------------------------------------------------------------- - -/// A `#[global_allocator]`-compatible handle to rimalloc. -#[derive(Clone, Copy, Default, Debug)] -pub struct Rimalloc; - -// SAFETY: rimalloc is a conforming allocator: blocks stay valid until freed, -// Layout size/align contracts are respected via the aligned paths. -unsafe impl GlobalAlloc for Rimalloc { - #[inline] - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - // `align <= MAX_ALIGN_SIZE` is NOT sufficient: the 8-byte size class - // is only 8-aligned, so a `{size: 1..=8, align: 16}` layout needs the - // aligned path. `is_naturally_aligned` is C's exact predicate. - if is_naturally_aligned(layout.size(), layout.align()) { - malloc(layout.size()) - } else { - malloc_aligned(layout.size(), layout.align()) - } - } - - #[inline] - unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { - free(ptr) - } - - #[inline] - unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { - if is_naturally_aligned(layout.size(), layout.align()) { - zalloc(layout.size()) - } else { - zalloc_aligned(layout.size(), layout.align()) - } - } - - #[inline] - unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { - if is_naturally_aligned(new_size, layout.align()) { - realloc(ptr, new_size) - } else { - realloc_aligned(ptr, new_size, layout.align()) - } - } -} - -// --------------------------------------------------------------------------- -// POSIX-style API (port of `alloc-posix.c`) -// --------------------------------------------------------------------------- - -/// `mi_posix_memalign`: returns 0, or EINVAL/ENOMEM like posix_memalign. -pub fn posix_memalign(out: &mut *mut u8, alignment: usize, size: usize) -> i32 { - if !alignment.is_multiple_of(size_of::()) || !is_power_of_two(alignment) { - return crate::EINVAL; - } - let p = malloc_zero_aligned_at(heap_default_unchecked(), size, alignment, 0, false); - if p.is_null() { - return crate::ENOMEM; - } - *out = p; - 0 -} - -/// `mi_memalign` -pub fn memalign(alignment: usize, size: usize) -> *mut u8 { - malloc_aligned(size, alignment) -} - -/// `mi_valloc` -pub fn valloc(size: usize) -> *mut u8 { - malloc_aligned(size, crate::os::page_size()) -} - -/// `mi_reallocarray` -pub fn reallocarray(p: *mut u8, count: usize, size: usize) -> *mut u8 { - match count_size(count, size) { - Some(total) => realloc(p, total), - None => ptr::null_mut(), - } -} - -// --------------------------------------------------------------------------- -// Usable-size returning variants (`mi_umalloc` family) -// --------------------------------------------------------------------------- - -/// `mi_umalloc` -pub fn umalloc(size: usize, usable: &mut usize) -> *mut u8 { - let p = malloc(size); - *usable = usable_size(p); - p -} - -/// `mi_urealloc` -pub fn urealloc(p: *mut u8, newsize: usize, pre: &mut usize, post: &mut usize) -> *mut u8 { - *pre = usable_size(p); - let q = realloc(p, newsize); - *post = usable_size(q); - q -} - -/// `mi_ufree` -pub fn ufree(p: *mut u8, usable: &mut usize) { - *usable = usable_size(p); - free(p); -} - -// --------------------------------------------------------------------------- -// Heap introspection -// --------------------------------------------------------------------------- - -/// `mi_heap_contains_block` -/// -/// # Safety -/// `p` must be null or a block allocated by this allocator. -pub unsafe fn heap_contains_block(heap: &Heap, p: *const u8) -> bool { - if p.is_null() { - return false; - } - // SAFETY: p must be a block allocated by us (API contract). - let page = unsafe { crate::segment::ptr_page(p) }; - page.heap() == ptr::from_ref(heap).cast_mut() -} - -/// `mi_heap_check_owned` -/// -/// # Safety -/// `p` must be null or a pointer into memory obtained from this allocator. -pub unsafe fn heap_check_owned(_heap: &Heap, p: *const u8) -> bool { - // SAFETY: per contract. - unsafe { check_owned(p) } -} - -/// `mi_zalloc_small` -#[inline] -pub fn zalloc_small(size: usize) -> *mut u8 { - debug_assert!(size <= SMALL_SIZE_MAX); - as_ptr(crate::alloc::heap_malloc_small_zero( - heap_default_unchecked(), - size, - true, - )) -} - -/// Current committed-byte count from the stats (diagnostic). -pub fn stats_committed_current() -> i64 { - crate::stats::STATS - .committed - .current - .load(core::sync::atomic::Ordering::Relaxed) -} - -/// Syscall counters `[mmap, munmap, madvise, mprotect]` (diagnostics). -pub fn os_syscall_counts() -> [usize; 4] { - crate::os::syscall_counts() -} - -/// Diagnostics: (abandoned segments, reclaim tries, reclaim hits). -pub fn abandoned_stats() -> (usize, usize, usize) { - use core::sync::atomic::Ordering; - ( - crate::segment::abandoned::count(), - crate::segment::abandoned::RECLAIM_TRIES.load(Ordering::Relaxed), - crate::segment::abandoned::RECLAIM_HITS.load(Ordering::Relaxed), - ) -} - -/// Diagnostics: number of initialized live threads. -pub fn current_thread_count_dbg() -> usize { - crate::init::current_thread_count() -} - -/// `mi_stats_print`: write allocator statistics to stderr. -pub fn stats_print() { - let _ = crate::stats::print(&mut std::io::stderr()); -} - -/// Write allocator statistics to an arbitrary writer. -pub fn stats_write(out: &mut dyn std::io::Write) -> std::io::Result<()> { - crate::stats::print(out) -} - -pub use crate::hooks::{register_deferred_free, register_error, register_output}; diff --git a/vendor/rimalloc/src/arena.rs b/vendor/rimalloc/src/arena.rs deleted file mode 100644 index 574d0d59..00000000 --- a/vendor/rimalloc/src/arena.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! Arenas (port of `arena.c`): large OS reservations (1GiB+, growing -//! geometrically) divided into segment-sized blocks tracked by atomic -//! bitmaps. Segments are claimed/released with no syscalls; freed blocks -//! are purged (`MADV_FREE`) lazily after a delay, keeping RSS bounded. - -use core::ptr; -use core::sync::atomic::{AtomicBool, AtomicI64, AtomicPtr, AtomicUsize, Ordering}; - -use crate::bitmap::{ - FIELD_BITS, claim_run_field, claim_single_field, clear_committed_run, decommit_run, field_mask, - prepare_run, purge_field, release_run, -}; -use crate::constants::*; -use crate::options; -use crate::os::{self, OsMem}; - -/// One arena block backs exactly one normal segment. -pub const ARENA_BLOCK_SIZE: usize = SEGMENT_SIZE; -const MAX_ARENAS: usize = 64; -/// Up to 16 GiB per arena (512 blocks, 8 bitmap fields). -const MAX_BLOCK_FIELDS: usize = 8; -const MAX_ARENA_BLOCKS: usize = MAX_BLOCK_FIELDS * FIELD_BITS; - -/// Arena identifier: 0 is "none" (any arena); real ids are `index + 1`. -pub type ArenaId = usize; -pub const ARENA_ID_NONE: ArenaId = 0; - -pub struct Arena { - id: ArenaId, - exclusive: bool, - numa_node: i32, - start: *mut u8, - block_count: usize, - #[allow(dead_code)] // owns the reservation for the process lifetime - mem: OsMem, - purge_expire: AtomicI64, - claim_lock: AtomicBool, // serializes multi-block claims only - blocks_inuse: [AtomicUsize; MAX_BLOCK_FIELDS], - blocks_dirty: [AtomicUsize; MAX_BLOCK_FIELDS], // ever used (not zero) - blocks_committed: [AtomicUsize; MAX_BLOCK_FIELDS], - blocks_purge: [AtomicUsize; MAX_BLOCK_FIELDS], // scheduled for purge -} - -// SAFETY: all mutable state is atomic. -unsafe impl Sync for Arena {} -// SAFETY: Arena owns its reservation; the raw pointers are plain data. -unsafe impl Send for Arena {} - -static ARENAS: [AtomicPtr; MAX_ARENAS] = - [const { AtomicPtr::new(ptr::null_mut()) }; MAX_ARENAS]; -static ARENA_COUNT: AtomicUsize = AtomicUsize::new(0); -/// Earliest pending purge across all arenas (0 = none): lock-free gate so -/// the allocation path never scans bitmaps needlessly. -static PURGE_EXPIRE: AtomicI64 = AtomicI64::new(0); -static RESERVE_LOCK: AtomicBool = AtomicBool::new(false); - -impl Arena { - #[inline] - fn block_ptr(&self, index: usize) -> *mut u8 { - // SAFETY: index < block_count, within the reservation. - unsafe { self.start.add(index * ARENA_BLOCK_SIZE) } - } - - /// Try to claim `count` contiguous blocks. Single blocks are lock-free; - /// multi-block claims serialize on a per-arena spinlock (rare: only - /// huge segments). - fn try_claim(&self, count: usize) -> Option { - let fields = self.block_count.div_ceil(FIELD_BITS); - if count == 1 { - for (i, field) in self.blocks_inuse[..fields].iter().enumerate() { - let limit = (self.block_count - i * FIELD_BITS).min(FIELD_BITS); - if let Some(bit) = claim_single_field(field, limit) { - return Some(i * FIELD_BITS + bit); - } - } - return None; - } - if count > FIELD_BITS { - return None; // >2GiB objects fall back to the OS - } - // multi-block: spinlock + scan within fields - crate::sync::spin_acquire(&self.claim_lock); - let mut found = None; - for (i, field) in self.blocks_inuse[..fields].iter().enumerate() { - let limit = (self.block_count - i * FIELD_BITS).min(FIELD_BITS); - if let Some(bit) = claim_run_field(field, limit, count) { - found = Some(i * FIELD_BITS + bit); - break; - } - } - crate::sync::spin_release(&self.claim_lock); - found - } - - /// Post-claim bookkeeping: ensure committed, compute zero/commit state. - fn prepare(&self, index: usize, count: usize) -> (bool, bool) { - let (fi, bit) = (index / FIELD_BITS, index % FIELD_BITS); - let is_zero = prepare_run( - &self.blocks_dirty[fi], - &self.blocks_committed[fi], - &self.blocks_purge[fi], - bit, - count, - || { - // SAFETY: the claimed range is exclusively ours. - unsafe { os::commit(self.block_ptr(index), count * ARENA_BLOCK_SIZE) }; - }, - ); - (is_zero, true) - } - - fn release(&self, index: usize, count: usize) { - let (fi, bit) = (index / FIELD_BITS, index % FIELD_BITS); - // schedule the physical pages for release before making the blocks - // claimable again - let expire = options::clock_now() + options::purge_delay().max(1); - self.purge_expire.store(expire, Ordering::Relaxed); - let cur = PURGE_EXPIRE.load(Ordering::Relaxed); - if cur == 0 || expire < cur { - PURGE_EXPIRE.store(expire, Ordering::Relaxed); - } - let prev = release_run(&self.blocks_inuse[fi], &self.blocks_purge[fi], bit, count); - debug_assert!(prev & field_mask(bit, count) == field_mask(bit, count)); - } - - /// Purge scheduled blocks that are still free (best effort; a racing - /// claim simply refaults). - fn purge_now(&self) { - let fields = self.block_count.div_ceil(FIELD_BITS); - let decommits = options::purge_decommits() != 0; - for fi in 0..fields { - purge_field( - &self.blocks_purge[fi], - &self.blocks_inuse[fi], - |bit, run_len| { - let index = fi * FIELD_BITS + bit; - if decommits { - // Take the run out of circulation while decommitting: - // a concurrent claim that saw the committed bits set - // must not touch memory mid-decommit. Skip runs that - // were (re)claimed since the purgeable snapshot. - decommit_run( - &self.blocks_inuse[fi], - &self.blocks_committed[fi], - bit, - run_len, - || { - // SAFETY: free blocks, exclusively held by us. - unsafe { - os::decommit(self.block_ptr(index), run_len * ARENA_BLOCK_SIZE); - } - }, - ); - } else { - // SAFETY: free blocks; contents may be discarded. - unsafe { - os::reset(self.block_ptr(index), run_len * ARENA_BLOCK_SIZE); - } - } - }, - ); - } - self.purge_expire.store(0, Ordering::Relaxed); - } -} - -/// Reserve a fresh arena. Size grows geometrically with the arena count. -fn arena_reserve() -> Option<&'static Arena> { - crate::sync::spin_locked(&RESERVE_LOCK, arena_reserve_locked) -} - -fn arena_reserve_locked() -> Option<&'static Arena> { - // MIMALLOC_ARENA_RESERVE (KiB) sets the base reservation; 0 disables - // arena reservation entirely (allocation falls back to the OS and the - // segment cache). - let reserve = crate::options::arena_reserve().max(0) as usize * 1024; - if reserve == 0 { - return None; - } - let n = ARENA_COUNT.load(Ordering::Acquire); - // base reservation, doubling every 2 arenas, capped at MAX_ARENA_BLOCKS. - let base_blocks = if cfg!(miri) { - 2 // miri: small host allocs - } else { - reserve.div_ceil(ARENA_BLOCK_SIZE).max(1) - }; - let blocks = (base_blocks << (n / 2)).min(MAX_ARENA_BLOCKS); - let size = blocks * ARENA_BLOCK_SIZE; - // Reserve uncommitted; blocks are committed on first claim. - let commit = cfg!(miri); - let (start, mem) = OsMem::alloc_aligned(size, SEGMENT_ALIGN, commit)?; - add_arena_locked(start, blocks, mem, false, -1, mem.is_zero) -} - -/// `mi_arena_add`: publish a new arena (assumes RESERVE_LOCK is held). -fn add_arena_locked( - start: *mut u8, - blocks: usize, - mem: OsMem, - exclusive: bool, - numa_node: i32, - is_zero: bool, -) -> Option<&'static Arena> { - let n = ARENA_COUNT.load(Ordering::Acquire); - if n >= MAX_ARENAS || blocks == 0 { - return None; - } - let header_size = align_up(size_of::(), os::page_size()); - let (hp, hmem) = OsMem::alloc_aligned(header_size, os::page_size(), true)?; - let arena_ptr = hp.cast::(); - // SAFETY: fresh committed memory sized for an Arena. - unsafe { - arena_ptr.write(Arena { - id: n + 1, - exclusive, - numa_node, - start, - block_count: blocks.min(MAX_ARENA_BLOCKS), - mem, - purge_expire: AtomicI64::new(0), - claim_lock: AtomicBool::new(false), - blocks_inuse: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], - blocks_dirty: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], - blocks_committed: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], - blocks_purge: [const { AtomicUsize::new(0) }; MAX_BLOCK_FIELDS], - }); - if !is_zero { - (*arena_ptr).blocks_dirty.iter().for_each(|f| { - f.store(!0, Ordering::Relaxed); - }); - } - if mem.is_committed { - (*arena_ptr).blocks_committed.iter().for_each(|f| { - f.store(!0, Ordering::Relaxed); - }); - } - } - let _ = hmem; // header lives for the process lifetime - ARENAS[n].store(arena_ptr, Ordering::Release); - ARENA_COUNT.store(n + 1, Ordering::Release); - // SAFETY: just initialized; arenas are never freed. - Some(unsafe { &*arena_ptr }) -} - -/// `mi_arena_id_is_suitable` -#[inline] -fn id_is_suitable(id: ArenaId, exclusive: bool, req: ArenaId) -> bool { - (!exclusive && req == ARENA_ID_NONE) || id == req -} - -/// `_mi_arena_memid_is_suitable` for an [`ArenaRef`]. -pub fn ref_is_suitable(aref: ArenaRef, req: ArenaId) -> bool { - // SAFETY: arenas are immortal. - let arena = unsafe { &*aref.arena }; - id_is_suitable(arena.id, arena.exclusive, req) -} - -/// The current NUMA node (`_mi_os_numa_node`); single-node on macOS. -pub fn numa_node() -> i32 { - 0 -} - -fn arenas() -> impl Iterator { - let n = ARENA_COUNT.load(Ordering::Acquire); - ARENAS[..n].iter().filter_map(|slot| { - // SAFETY: published arenas are initialized and immortal. - unsafe { slot.load(Ordering::Acquire).as_ref() } - }) -} - -/// Provenance of an arena allocation (stored in the segment's `MemId`). -#[derive(Clone, Copy, Debug)] -pub struct ArenaRef { - arena: *const Arena, - index: u32, - count: u32, -} - -/// Allocate `count` contiguous arena blocks (SEGMENT_ALIGN aligned) from an -/// arena suitable for `req` (preferring the local NUMA node). -/// Returns `(ptr, is_zero, ArenaRef)`. -pub fn alloc(count: usize, req: ArenaId) -> Option<(*mut u8, bool, ArenaRef)> { - try_purge(false); - let node = numa_node(); - loop { - // pass 1: NUMA-local (or unbound) suitable arenas; pass 2: any suitable - for numa_strict in [true, false] { - for arena in arenas() { - if !id_is_suitable(arena.id, arena.exclusive, req) - || (numa_strict && arena.numa_node >= 0 && arena.numa_node != node) - { - continue; - } - if let Some(index) = arena.try_claim(count) { - let (is_zero, _committed) = arena.prepare(index, count); - let aref = ArenaRef { - arena: ptr::from_ref(arena), - index: index as u32, - count: count as u32, - }; - return Some((arena.block_ptr(index), is_zero, aref)); - } - } - } - if req != ARENA_ID_NONE { - return None; // a specific arena was requested and it is full - } - arena_reserve()?; - } -} - -/// `mi_reserve_os_memory_ex`: reserve fresh OS memory as an arena. -pub fn reserve_os_memory(size: usize, commit: bool, exclusive: bool) -> Option { - let blocks = size.div_ceil(ARENA_BLOCK_SIZE); - let (start, mem) = OsMem::alloc_aligned(blocks * ARENA_BLOCK_SIZE, SEGMENT_ALIGN, commit)?; - locked_add(start, blocks, mem, exclusive, -1, mem.is_zero) -} - -/// `mi_manage_os_memory_ex`: adopt externally-provided memory as an arena. -/// The memory is never freed by rimalloc. -/// -/// # Safety -/// `start..start+size` must be valid, otherwise-unused memory that outlives -/// the process's use of this allocator; `is_committed`/`is_zero` must be -/// accurate. -pub unsafe fn manage_os_memory( - start: *mut u8, - size: usize, - is_committed: bool, - is_zero: bool, - numa_node: i32, - exclusive: bool, -) -> Option { - // align inward to whole blocks - let astart = align_up(start.addr(), SEGMENT_ALIGN); - let aend = align_down(start.addr() + size, ARENA_BLOCK_SIZE.max(SEGMENT_ALIGN)); - if aend <= astart { - return None; - } - let blocks = (aend - astart) / ARENA_BLOCK_SIZE; - let astart_ptr = start.with_addr(astart); - let mem = OsMem { - base: astart_ptr, - size: blocks * ARENA_BLOCK_SIZE, - align: SEGMENT_ALIGN, - is_committed, - is_zero, - }; - astart_ptr.expose_provenance(); // segment lookups reconstruct by address - locked_add(astart_ptr, blocks, mem, exclusive, numa_node, is_zero) -} - -fn locked_add( - start: *mut u8, - blocks: usize, - mem: OsMem, - exclusive: bool, - numa_node: i32, - is_zero: bool, -) -> Option { - crate::sync::spin_locked(&RESERVE_LOCK, || { - add_arena_locked(start, blocks, mem, exclusive, numa_node, is_zero).map(|a| a.id) - }) -} - -/// Address range check (`mi_is_in_heap_region` support). -pub fn contains(p: *const u8) -> bool { - arenas().any(|a| { - p.addr() >= a.start.addr() && p.addr() < a.start.addr() + a.block_count * ARENA_BLOCK_SIZE - }) -} - -/// Return blocks to their arena. -pub fn free(aref: ArenaRef, all_committed: bool) { - // SAFETY: arenas are immortal. - let arena = unsafe { &*aref.arena }; - if !all_committed { - // The dying segment decommitted parts of this range (the arena - // tracks commitment at block granularity): mark the whole range - // uncommitted so the next claim recommits it (C `_mi_arena_free`), - // preventing a re-claim from handing out PROT_NONE memory. - let (fi, bit) = ( - aref.index as usize / FIELD_BITS, - aref.index as usize % FIELD_BITS, - ); - clear_committed_run(&arena.blocks_committed[fi], bit, aref.count as usize); - } - arena.release(aref.index as usize, aref.count as usize); -} - -/// Execute pending purges if due (cheap lock-free check on the fast path). -pub fn try_purge(force: bool) { - let expire = PURGE_EXPIRE.load(Ordering::Relaxed); - if expire == 0 { - return; - } - let now = options::clock_now(); - if !force && now < expire { - return; - } - PURGE_EXPIRE.store(0, Ordering::Relaxed); - for arena in arenas() { - let e = arena.purge_expire.load(Ordering::Relaxed); - if e != 0 && (force || now >= e) { - arena.purge_now(); - } - } -} - -/// fork() support: hold the reservation lock and every arena's claim lock -/// across the fork so the child's bitmaps are consistent. -#[cfg(not(windows))] -pub(crate) fn lock_for_fork() { - crate::sync::spin_acquire(&RESERVE_LOCK); - for arena in arenas() { - crate::sync::spin_acquire(&arena.claim_lock); - } -} - -#[cfg(not(windows))] -pub(crate) fn unlock_for_fork() { - for arena in arenas() { - crate::sync::spin_release(&arena.claim_lock); - } - crate::sync::spin_release(&RESERVE_LOCK); -} diff --git a/vendor/rimalloc/src/bins.rs b/vendor/rimalloc/src/bins.rs deleted file mode 100644 index 686732b7..00000000 --- a/vendor/rimalloc/src/bins.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Size-class ("bin") mapping, mirroring `page-queue.c`. -//! -//! Bins are spaced exponentially in 12.5% increments: sizes of at most 8 -//! words get an exact bin; above that the top three bits of the word size -//! select the bin. `MAX_ALIGN_SIZE == 16 == 2*PTR_SIZE` gives the `ALIGN2W` -//! variant: small sizes round up to double-word multiples. - -use crate::constants::*; - -/// Index of a heap page queue: `1..=BIN_HUGE` are size classes, `BIN_FULL` -/// is the queue of full pages. Constructed only through [`Bin::from_size`] -/// or the named constants, so indexing `[_; BIN_FULL + 1]` never re-checks. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct Bin(u8); - -impl Bin { - pub const HUGE: Bin = Bin(BIN_HUGE as u8); - pub const FULL: Bin = Bin(BIN_FULL as u8); - - /// `mi_bin`: the bin for a given byte size (huge if too large). - #[inline] - pub fn from_size(size: usize) -> Bin { - let mut wsize = wsize_from_size(size); - let bin = if wsize <= 8 { - // ALIGN2W: round to double-word sizes. - if wsize <= 1 { 1 } else { (wsize + 1) & !1 } - } else if wsize > MEDIUM_OBJ_WSIZE_MAX { - BIN_HUGE - } else { - wsize -= 1; - // Use the top 3 bits of the word size; adjust by 3 because the - // first 8 sizes get an exact bin. - let b = (usize::BITS - 1 - wsize.leading_zeros()) as usize; - let bin = ((b << 2) + ((wsize >> (b - 2)) & 0x03)) - 3; - debug_assert!(bin > 0 && bin < BIN_HUGE); - bin - }; - Bin(bin as u8) - } - - #[inline(always)] - pub const fn index(self) -> usize { - self.0 as usize - } - - /// Largest block size (in bytes) served by this bin (`_mi_bin_size`). - #[inline] - pub const fn block_size(self) -> usize { - BIN_WSIZE[self.0 as usize] * PTR_SIZE - } -} - -/// Largest word size per bin — the inverse of [`Bin::from_size`], matching -/// `MI_PAGE_QUEUES_EMPTY` in `init.c`. Entry 0 is a sentinel; the last two -/// entries are the huge and full queue markers. -pub const BIN_WSIZE: [usize; BIN_FULL + 1] = { - let mut t = [0usize; BIN_FULL + 1]; - t[0] = 1; - let mut bin = 1; - while bin <= 8 { - t[bin] = bin; - bin += 1; - } - while bin < BIN_HUGE { - let b = (bin + 3) >> 2; - let m = (bin + 3) & 3; - t[bin] = (1 << b) + ((m + 1) << (b - 2)); - bin += 1; - } - t[BIN_HUGE] = MEDIUM_OBJ_WSIZE_MAX + 1; // huge queue - t[BIN_FULL] = MEDIUM_OBJ_WSIZE_MAX + 2; // full queue - t -}; - -/// `mi_good_size`: the actual usable size class an allocation rounds to. -#[inline] -pub fn good_size(size: usize) -> usize { - if size <= MEDIUM_OBJ_SIZE_MAX { - Bin::from_size(size + PADDING_SIZE).block_size() - } else if size <= MAX_ALLOC_SIZE { - align_up(size + PADDING_SIZE, crate::os::page_size()) - } else { - size - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn table_matches_reference() { - // Spot-check against the table in init.c. - let expect: &[(usize, usize)] = &[ - (1, 1), - (8, 8), - (9, 10), - (16, 32), - (17, 40), - (24, 128), - (32, 512), - (40, 2048), - (48, 8192), - (56, 32768), - (64, 131072), - (72, 524288), - ]; - for &(bin, wsize) in expect { - assert_eq!(BIN_WSIZE[bin], wsize, "bin {bin}"); - } - } - - #[test] - fn bin_is_inverse_of_table() { - for bin in 1..BIN_HUGE { - let wsize = BIN_WSIZE[bin]; - if wsize > MEDIUM_OBJ_WSIZE_MAX { - continue; - } - let chosen = Bin::from_size(wsize * PTR_SIZE); - // ALIGN2W: odd word sizes <= 8 round up to the next even bin. - let expect = if wsize <= 8 && wsize > 1 { - (bin + 1) & !1 - } else { - bin - }; - assert_eq!(chosen.index(), expect, "max of bin {bin}"); - // The chosen bin must fit the size... - assert!(chosen.block_size() >= wsize * PTR_SIZE); - // ...and one word more must spill into a later bin. - assert!(Bin::from_size((wsize + 1) * PTR_SIZE).index() > expect.min(bin)); - } - assert_eq!(Bin::from_size(MEDIUM_OBJ_SIZE_MAX + 1), Bin::HUGE); - assert_eq!(Bin::from_size(0).index(), 1); - } -} diff --git a/vendor/rimalloc/src/bitmap.rs b/vendor/rimalloc/src/bitmap.rs deleted file mode 100644 index b710a94a..00000000 --- a/vendor/rimalloc/src/bitmap.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! The arena claim/commit/purge bitmap protocol — the *single* -//! implementation, generic over the atomic word type. Production -//! (`arena.rs`) instantiates it with `core` atomics on the live `Arena` -//! bitmap fields (`blocks_inuse`/`blocks_committed`/`blocks_dirty`/ -//! `blocks_purge`); `tests/loom_arena.rs` instantiates the *same functions* -//! with loom atomics, so every CAS, ordering, and bit transition of the -//! real lock-free protocol is model-checked across all interleavings. -//! -//! The OS side effects (`commit`/`decommit`/`reset`) are abstracted as -//! closures, mirroring how `protocol.rs` abstracts block storage: production -//! supplies the real syscalls, loom supplies shadow-flag mutations (loom -//! cannot model real mmap). All bit twiddling, memory orderings, and the -//! claim/purge non-interference handshake live here and only here. - -use core::sync::atomic::Ordering; - -pub const FIELD_BITS: usize = usize::BITS as usize; - -/// Atomic-word interface implemented by `core` and loom `AtomicUsize`. -/// Richer than `protocol::AtomicWord` (adds `fetch_or`/`fetch_and`) because -/// the bitmaps set and clear bit ranges, not just CAS a packed word. -pub trait BitmapWord { - fn load(&self, order: Ordering) -> usize; - fn store(&self, val: usize, order: Ordering); - fn swap(&self, val: usize, order: Ordering) -> usize; - fn fetch_or(&self, val: usize, order: Ordering) -> usize; - fn fetch_and(&self, val: usize, order: Ordering) -> usize; - fn compare_exchange_weak( - &self, - current: usize, - new: usize, - success: Ordering, - failure: Ordering, - ) -> Result; -} - -impl BitmapWord for core::sync::atomic::AtomicUsize { - fn load(&self, order: Ordering) -> usize { - self.load(order) - } - fn store(&self, val: usize, order: Ordering) { - self.store(val, order) - } - fn swap(&self, val: usize, order: Ordering) -> usize { - self.swap(val, order) - } - fn fetch_or(&self, val: usize, order: Ordering) -> usize { - self.fetch_or(val, order) - } - fn fetch_and(&self, val: usize, order: Ordering) -> usize { - self.fetch_and(val, order) - } - fn compare_exchange_weak( - &self, - current: usize, - new: usize, - success: Ordering, - failure: Ordering, - ) -> Result { - self.compare_exchange_weak(current, new, success, failure) - } -} - -/// `1`-bits for `[bit, bit+count)` within one field. -#[inline] -pub fn field_mask(bit: usize, count: usize) -> usize { - debug_assert!(count >= 1 && bit + count <= FIELD_BITS); - if count == FIELD_BITS { - !0 - } else { - ((1usize << count) - 1) << bit - } -} - -/// Lock-free single-block claim within one field (the hot path's inner CAS -/// loop). `limit` is the number of valid blocks in this field (`<= -/// FIELD_BITS`). Returns the claimed bit index on success, or `None` if the -/// field is full. This is the lost-update / double-claim hazard: the CAS -/// guarantees no two threads ever take the same bit. -pub fn claim_single_field(inuse: &W, limit: usize) -> Option { - let mut cur = inuse.load(Ordering::Relaxed); - loop { - let avail = if limit == FIELD_BITS { - !0 - } else { - (1usize << limit) - 1 - }; - let free = !cur & avail; - if free == 0 { - return None; - } - let bit = free.trailing_zeros() as usize; - match inuse.compare_exchange_weak( - cur, - cur | (1 << bit), - Ordering::AcqRel, - Ordering::Relaxed, - ) { - Ok(_) => return Some(bit), - Err(v) => cur = v, - } - } -} - -/// Multi-block claim within one field, *under the caller's claim lock*: scan -/// for a `count`-wide free run and take it with a CAS. `limit` is the number -/// of valid blocks in this field. Returns the run's start bit on success. -/// The caller serializes these on the per-arena spinlock (excluding other -/// multi-block claims), but the lock-free single-block path and purge -/// race-modify the same field, so the take must be a CAS that re-validates -/// the run is *still* free — a blind `fetch_or` would co-claim a bit a -/// single-block CAS grabbed between the scan's load and the take. -pub fn claim_run_field(inuse: &W, limit: usize, count: usize) -> Option { - if limit < count { - return None; - } - let mut cur = inuse.load(Ordering::Relaxed); - let mut bit = 0; - while bit <= limit - count { - let mask = field_mask(bit, count); - if cur & mask != 0 { - bit += 1; - continue; - } - match inuse.compare_exchange_weak(cur, cur | mask, Ordering::AcqRel, Ordering::Relaxed) { - Ok(_) => return Some(bit), - // a racing single-block claim or purge moved the field: rescan - // from the start against the fresh value without advancing `bit`. - Err(v) => { - cur = v; - bit = 0; - } - } - } - None -} - -/// Post-claim commit bookkeeping for a `[bit, bit+count)` run in one field. -/// Sets the dirty and committed bits, clears any pending purge, and — when -/// the committed bit was *not* already set — invokes `commit` (the OS -/// recommit, or under loom the shadow-flag set). Returns `is_zero` (the -/// range was never dirtied). This is the commit-before-use guarantee: a -/// claimer that observes the committed bit clear MUST recommit before the -/// caller reads the memory. -pub fn prepare_run( - dirty: &W, - committed: &W, - purge: &W, - bit: usize, - count: usize, - commit: impl FnOnce(), -) -> bool { - let mask = field_mask(bit, count); - let was_dirty = dirty.fetch_or(mask, Ordering::AcqRel) & mask; - let was_committed = committed.fetch_or(mask, Ordering::AcqRel) & mask; - purge.fetch_and(!mask, Ordering::AcqRel); - if was_committed != mask { - commit(); - } - was_dirty == 0 -} - -/// Schedule a `[bit, bit+count)` run for purge and make it claimable again. -/// Sets the purge bits (so a later `purge_field` can decommit the still-free -/// run), then clears the inuse bits. The order matters: purge must be -/// visible before the blocks become claimable, so a re-claimer that sees the -/// committed bit set is the one responsible for the live commit state. -/// Returns the previous inuse word (the caller asserts the run was held). -pub fn release_run(inuse: &W, purge: &W, bit: usize, count: usize) -> usize { - let mask = field_mask(bit, count); - purge.fetch_or(mask, Ordering::AcqRel); - inuse.fetch_and(!mask, Ordering::AcqRel) -} - -/// Clear the committed bits for a `[bit, bit+count)` run (the cross-layer -/// fix in `arena::free`): a dying segment that decommitted parts of its -/// range marks the whole range uncommitted so the next claimer recommits it, -/// never handing out PROT_NONE memory. -pub fn clear_committed_run(committed: &W, bit: usize, count: usize) { - committed.fetch_and(!field_mask(bit, count), Ordering::AcqRel); -} - -/// Drive the decommit half of `purge_now` for one field. Takes the pending -/// purge word, restricts it to blocks still free (not reclaimed since), and -/// for each maximal free run in *decommit* mode hands it to `decommit_run`. -/// -/// `decommit_run(bit, len)` performs the non-interference handshake: take the -/// run out of circulation via `inuse.fetch_or` so a concurrent claimer that -/// saw the committed bits set cannot touch memory mid-decommit, clear the -/// committed bit, decommit (OS, or under loom clear the shadow flag), then -/// return the run to circulation. Runs reclaimed between the snapshot and the -/// `fetch_or` are skipped. `decommit_run` returns the run mask it actually -/// decommitted (0 if skipped). In *reset* mode the caller passes a -/// `decommit_run` that just resets without touching the bitmaps. -pub fn purge_field(purge: &W, inuse: &W, mut purge_run: impl FnMut(usize, usize)) { - let pending = purge.swap(0, Ordering::AcqRel); - if pending == 0 { - return; - } - let purgeable = pending & !inuse.load(Ordering::Acquire); - let mut m = purgeable; - while m != 0 { - let bit = m.trailing_zeros() as usize; - let mut run_len = 0; - while bit + run_len < FIELD_BITS && m & (1 << (bit + run_len)) != 0 { - run_len += 1; - } - purge_run(bit, run_len); - m &= !field_mask(bit, run_len); - } -} - -/// The decommit-mode handshake for a single run in `purge_field`: take it out -/// of circulation, and if it was still free, clear committed + decommit + -/// return it. `decommit(bit, len)` is the OS syscall (or loom shadow clear). -/// Returns `true` if the run was decommitted (vs. skipped because reclaimed). -pub fn decommit_run( - inuse: &W, - committed: &W, - bit: usize, - count: usize, - decommit: impl FnOnce(), -) -> bool { - let run = field_mask(bit, count); - if inuse.fetch_or(run, Ordering::AcqRel) & run == 0 { - committed.fetch_and(!run, Ordering::AcqRel); - decommit(); - inuse.fetch_and(!run, Ordering::AcqRel); - true - } else { - false - } -} diff --git a/vendor/rimalloc/src/constants.rs b/vendor/rimalloc/src/constants.rs deleted file mode 100644 index 412c872d..00000000 --- a/vendor/rimalloc/src/constants.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Core size constants, mirroring `mimalloc/types.h` (64-bit values). - -pub const PTR_SIZE: usize = size_of::(); -pub const PTR_SHIFT: usize = PTR_SIZE.trailing_zeros() as usize; - -/// Minimal alignment necessary (`MI_MAX_ALIGN_SIZE`), 16 for SSE et al. -pub const MAX_ALIGN_SIZE: usize = 16; - -pub const SEGMENT_SLICE_SHIFT: usize = 13 + PTR_SHIFT; // 64 KiB on 64-bit -pub const SEGMENT_SHIFT: usize = if PTR_SIZE > 4 { - 9 + SEGMENT_SLICE_SHIFT // 32 MiB -} else { - 7 + SEGMENT_SLICE_SHIFT // 4 MiB on 32-bit -}; -pub const SMALL_PAGE_SHIFT: usize = SEGMENT_SLICE_SHIFT; // 64 KiB -pub const MEDIUM_PAGE_SHIFT: usize = 3 + SMALL_PAGE_SHIFT; // 512 KiB - -pub const SEGMENT_SIZE: usize = 1 << SEGMENT_SHIFT; -pub const SEGMENT_ALIGN: usize = SEGMENT_SIZE; -pub const SEGMENT_MASK: usize = SEGMENT_ALIGN - 1; -pub const SEGMENT_SLICE_SIZE: usize = 1 << SEGMENT_SLICE_SHIFT; -pub const SLICES_PER_SEGMENT: usize = SEGMENT_SIZE / SEGMENT_SLICE_SIZE; // 512 - -pub const SMALL_PAGE_SIZE: usize = 1 << SMALL_PAGE_SHIFT; -pub const MEDIUM_PAGE_SIZE: usize = 1 << MEDIUM_PAGE_SHIFT; - -pub const SMALL_OBJ_SIZE_MAX: usize = SMALL_PAGE_SIZE / 8; // 8 KiB -pub const MEDIUM_OBJ_SIZE_MAX: usize = MEDIUM_PAGE_SIZE / 8; // 64 KiB -pub const MEDIUM_OBJ_WSIZE_MAX: usize = MEDIUM_OBJ_SIZE_MAX / PTR_SIZE; -pub const LARGE_OBJ_SIZE_MAX: usize = SEGMENT_SIZE / 2; // 16 MiB - -/// `MI_SMALL_WSIZE_MAX`: max word-size served by the direct small-alloc path. -pub const SMALL_WSIZE_MAX: usize = 128; -pub const SMALL_SIZE_MAX: usize = SMALL_WSIZE_MAX * PTR_SIZE; // 1 KiB - -/// Blocks up to this size are guaranteed block-size aligned. -pub const MAX_ALIGN_GUARANTEE: usize = MEDIUM_OBJ_SIZE_MAX; -/// Alignments above this go into dedicated huge segments. -pub const BLOCK_ALIGNMENT_MAX: usize = SEGMENT_SIZE >> 1; -/// Maximum slice count for which interior pointers can find their page. -pub const MAX_SLICE_OFFSET_COUNT: usize = (BLOCK_ALIGNMENT_MAX / SEGMENT_SLICE_SIZE) - 1; - -/// `MI_MAX_ALLOC_SIZE`: largest request the allocator accepts. On 64-bit the -/// cap keeps a segment's slice count within 32 bits (mimalloc issue #877), -/// well below `isize::MAX`, so `good_size`'s page round-up cannot overflow. -pub const MAX_ALLOC_SIZE: usize = SEGMENT_SLICE_SIZE * (u32::MAX as usize - 1); - -// Commit mask: one bit per commit chunk (= one slice) of a segment. -pub const MINIMAL_COMMIT_SIZE: usize = SEGMENT_SLICE_SIZE; -pub const COMMIT_SIZE: usize = SEGMENT_SLICE_SIZE; // 64 KiB -pub const COMMIT_MASK_BITS: usize = SEGMENT_SIZE / COMMIT_SIZE; -pub const COMMIT_MASK_FIELD_BITS: usize = usize::BITS as usize; -pub const COMMIT_MASK_FIELD_COUNT: usize = COMMIT_MASK_BITS / COMMIT_MASK_FIELD_BITS; -const _: () = assert!(COMMIT_MASK_BITS == COMMIT_MASK_FIELD_COUNT * COMMIT_MASK_FIELD_BITS); - -/// `MI_SEGMENT_BIN_MAX == mi_segment_bin(MI_SLICES_PER_SEGMENT)`. -pub const SEGMENT_BIN_MAX: usize = 35; - -/// Number of exponential size bins (`MI_BIN_HUGE`), plus the full-queue bin. -pub const BIN_HUGE: usize = 73; -pub const BIN_FULL: usize = BIN_HUGE + 1; - -/// `MI_PAGES_DIRECT`: entries in the heap's direct small-page array. -pub const PAGES_DIRECT: usize = SMALL_WSIZE_MAX + PADDING_WSIZE + 1; - -/// Padding (`MI_PADDING`) is disabled in release parity builds. -pub const PADDING_SIZE: usize = 0; -pub const PADDING_WSIZE: usize = 0; - -/// Convert a byte size to a machine-word count, rounding up. -#[inline(always)] -pub const fn wsize_from_size(size: usize) -> usize { - size.div_ceil(PTR_SIZE) -} - -#[inline(always)] -pub const fn align_up(sz: usize, alignment: usize) -> usize { - debug_assert!(alignment != 0); - let mask = alignment - 1; - if alignment & mask == 0 { - (sz + mask) & !mask - } else { - ((sz + mask) / alignment) * alignment - } -} - -#[inline(always)] -pub const fn align_down(sz: usize, alignment: usize) -> usize { - debug_assert!(alignment != 0); - let mask = alignment - 1; - if alignment & mask == 0 { - sz & !mask - } else { - (sz / alignment) * alignment - } -} - -#[inline(always)] -pub const fn is_power_of_two(x: usize) -> bool { - x != 0 && x & (x - 1) == 0 -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `MAX_ALLOC_SIZE` matches C v2.3.2's 64-bit value (issue #877) and stays - /// below `isize::MAX` so `good_size`'s page round-up cannot overflow. - #[test] - fn max_alloc_size_matches_c() { - assert_eq!(MAX_ALLOC_SIZE, SEGMENT_SLICE_SIZE * (u32::MAX as usize - 1)); - assert!(MAX_ALLOC_SIZE < isize::MAX as usize); - } -} diff --git a/vendor/rimalloc/src/free.rs b/vendor/rimalloc/src/free.rs deleted file mode 100644 index a8d4b552..00000000 --- a/vendor/rimalloc/src/free.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! The free path (port of `free.c`), including cross-thread frees that push -//! onto the page's atomic `xthread_free` list or the heap's delayed list. - -use core::ptr; -use core::sync::atomic::Ordering; - -use crate::page::{Block, Delayed, Page}; -use crate::page_ops; -use crate::segment::{Segment, SegmentKind, ptr_segment}; - -/// `mi_check_is_double_free` (`secure` feature): if the block's first word -/// decodes to a plausible pointer into the same page, confirm by walking -/// the page's free lists for an exact match. Reads one word of possibly -/// uninitialized user memory, so it is compiled out under Miri. -#[cfg(all(feature = "secure", not(miri)))] -fn check_is_double_free(page: &Page, block: *mut Block) -> bool { - // SAFETY: block points into the page's committed area. - let n = unsafe { page.block_next(block) }; - let start = page.page_start.get().addr(); - let end = start + page.capacity.get() as usize * page.block_size(); - let suspicious = n.addr() % size_of::() == 0 - && (n.is_null() || (n.addr() >= start && n.addr() < end)); - if !suspicious { - return false; - } - // confirm: is `block` actually on one of the free lists? - let on_list = |mut p: *mut Block| { - let mut count = page.capacity.get() as usize; - while !p.is_null() && count > 0 { - if p == block { - return true; - } - // SAFETY: list nodes are live blocks of this page. - unsafe { - p = page.block_next(p); - } - count -= 1; - } - false - }; - if on_list(page.free.get()) - || on_list(page.local_free.get()) - || on_list(page.thread_free().block()) - { - crate::error("double free detected"); - return true; - } - false -} - -#[cfg(not(all(feature = "secure", not(miri))))] -#[inline(always)] -fn check_is_double_free(_page: &Page, _block: *mut Block) -> bool { - false -} - -/// `mi_free_block_local`: thread-local free; push on `local_free`. -#[inline(always)] -fn free_block_local(page: &Page, block: *mut Block, check_full: bool) { - if check_is_double_free(page, block) { - return; - } - if cfg!(feature = "debug-fill") && !page.is_huge.get() { - // MI_DEBUG_FREED: stamp freed blocks to surface use-after-free. - // SAFETY: the block is dead; the next-pointer word is rewritten below. - unsafe { - core::ptr::write_bytes(block.cast::(), 0xDF, page.block_size()); - } - } - // SAFETY: the caller owns this (possibly partially-initialized) block; - // write-only, as the old contents may be uninitialized. - unsafe { page.block_set_next(block, page.local_free.get()) }; - page.local_free.set(block); - let used = page.used.get() - 1; - page.used.set(used); - if used == 0 { - page_ops::page_retire(page); - } else if check_full && page.in_full() { - page_ops::page_unfull(page); - } -} - -/// `_mi_page_ptr_unalign`: start of the block containing `p`. -pub fn page_ptr_unalign(page: &Page, p: *mut u8) -> *mut Block { - let diff = p.addr() - page.page_start.get().addr(); - let shift = page.block_size_shift.get(); - let adjust = if shift != 0 { - diff & ((1 << shift) - 1) - } else { - let recip = page.block_recip.get(); - if recip != 0 { - // Lemire fastmod, exact since `diff` and `block_size` are < 2^32 - // whenever the reciprocal is set (the page area of any page with - // a < 4 GiB block size is itself < 4 GiB). - let low = recip.wrapping_mul(diff as u64); - let adjust = ((low as u128 * page.block_size.get() as u128) >> 64) as usize; - debug_assert_eq!(adjust, diff % page.block_size.get()); - adjust - } else { - diff % page.block_size.get() - } - }; - p.map_addr(|a| a - adjust).cast() -} - -/// `mi_free_generic_local` -fn free_generic_local(page: &Page, p: *mut u8) { - let block = if page.has_aligned() { - page_ptr_unalign(page, p) - } else { - Block::at(p) - }; - free_block_local(page, block, true); -} - -/// `mi_free_block_delayed_mt`: push a foreign-owned block on the page's -/// thread-free list, or the owning heap's delayed list if this is the first -/// concurrent free into a full page. -fn free_block_delayed_mt(page: &Page, block: *mut Block) { - crate::protocol::mt_push( - &page.xthread_free, - || { - // The racy heap read is safe because FREEING is held. - let heap = page.xheap.load(Ordering::Acquire); - // SAFETY: heap stays valid while any page holds FREEING. - unsafe { - ptr::with_exposed_provenance::(heap) - .as_ref() - .map(|h| &h.thread_delayed_free) - } - }, - block.expose_provenance(), - |next| { - // SAFETY: we own the dying block; write-only (its contents may - // be uninitialized user memory). Page-keyed encoding for the - // page list; the heap-keyed delayed encoding is applied by the - // owner's decode in heap_delayed_free_partial symmetrically. - unsafe { - page.block_set_next(block, ptr::with_exposed_provenance_mut(next)); - } - }, - ); -} - -/// Abandoned-segment handling for a cross-thread free: try to reclaim the -/// segment into our heap so the free becomes local. Outlined so the hot -/// `free_block_mt` path pays no TLS access for this rare case. -#[cold] -#[inline(never)] -fn free_block_mt_reclaim(segment: &Segment, p: *mut u8) -> bool { - if !crate::init::thread_is_initialized() { - return false; - } - let heap = crate::init::heap_default(); - if crate::segment::attempt_reclaim(heap, segment) { - // now it is a local free in our heap - free(p); - true - } else { - false - } -} - -/// `mi_free_block_mt` -fn free_block_mt(page: &Page, segment: &Segment, block: *mut Block, p: *mut u8) { - // Try to reclaim an abandoned segment into our heap first (off by - // default, as in C mimalloc's `abandoned_reclaim_on_free`). - if segment.is_abandoned() - && crate::options::abandoned_reclaim_on_free() != 0 - && free_block_mt_reclaim(segment, p) - { - return; - } - if segment.kind.get() == SegmentKind::Huge { - crate::segment::huge_page_reset(segment, page, block); - } - free_block_delayed_mt(page, block); -} - -/// `mi_free_generic_mt` -#[inline(always)] -fn free_generic_mt(page: &Page, segment: &Segment, p: *mut u8) { - // don't check `has_aligned` to avoid a race (issue #865) - let block = page_ptr_unalign(page, p); - free_block_mt(page, segment, block, p); -} - -/// `mi_free` -#[inline(always)] -pub fn free(p: *mut u8) { - if p.is_null() || (p.addr() as isize) <= 0 { - return; - } - // SAFETY: a non-null `p` handed to free must come from our allocator, - // hence points into a live segment. - let segment = unsafe { ptr_segment(p) }; - let is_local = crate::init::thread_id() == segment.thread_id.load(Ordering::Relaxed); - let page = segment.page_of(p); - if is_local { - if !page.full_or_aligned() { - free_block_local(page, Block::at(p), false); - } else { - free_generic_local(page, p); - } - } else { - free_generic_mt(page, segment, p); - } -} - -/// `_mi_free_delayed_block`: returns false if contended. -pub fn free_delayed_block(block: *mut Block) -> bool { - // SAFETY: delayed blocks live in pages of segments owned by this thread. - let segment = unsafe { ptr_segment(block.cast()) }; - debug_assert!(crate::init::thread_id() == segment.thread_id.load(Ordering::Relaxed)); - let page = segment.page_of(block.cast()); - - // Re-enable delayed freeing before collecting, or blocks could get - // stranded on the page thread_free list with nothing on the heap list. - if !page_ops::page_try_use_delayed_free(page, Delayed::UseDelayedFree, false) { - return false; - } - page_ops::page_free_collect(page, false); - free_block_local(page, block, true); - true -} - -/// `mi_usable_size` -pub fn usable_size(p: *const u8) -> usize { - if p.is_null() { - return 0; - } - // SAFETY: p was allocated by us (API contract). - let segment = unsafe { ptr_segment(p) }; - let page = segment.page_of(p); - if !page.has_aligned() { - page.usable_block_size() - } else { - // adjust for interior aligned pointers - let block = page_ptr_unalign(page, p.cast_mut()); - let size = page.usable_block_size(); - let adjust = p.addr() - block.addr(); - debug_assert!(adjust <= size); - size - adjust - } -} diff --git a/vendor/rimalloc/src/heap.rs b/vendor/rimalloc/src/heap.rs deleted file mode 100644 index 1e545c66..00000000 --- a/vendor/rimalloc/src/heap.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Heaps (`mi_heap_t`): per-thread sets of page queues, one per size bin, -//! plus the `pages_free_direct` array that maps small word-sizes straight to -//! a page for the malloc fast path. - -use core::cell::Cell; -use core::ptr; -use core::sync::atomic::AtomicUsize; - -use crate::bins::{BIN_WSIZE, Bin}; -use crate::constants::*; -use crate::page::Page; -use crate::segment::SegmentsTld; - -/// `mi_page_queue_t`: doubly-linked queue of pages of one block size. -pub struct PageQueue { - pub first: Cell<*mut Page>, - pub last: Cell<*mut Page>, - pub block_size: usize, -} - -impl PageQueue { - /// `mi_page_queue_is_huge` - #[inline] - pub fn is_huge(&self) -> bool { - self.block_size == (MEDIUM_OBJ_SIZE_MAX + PTR_SIZE) - } - /// `mi_page_queue_is_full` - #[inline] - pub fn is_full(&self) -> bool { - self.block_size == (MEDIUM_OBJ_SIZE_MAX + 2 * PTR_SIZE) - } - /// `mi_page_queue_is_special` - #[inline] - pub fn is_special(&self) -> bool { - self.block_size > MEDIUM_OBJ_SIZE_MAX - } -} - -/// Thread-local data (`mi_tld_t`). -pub struct Tld { - pub heartbeat: Cell, - pub recurse: Cell, - pub heap_backing: Cell<*mut Heap>, - pub heaps: Cell<*mut Heap>, // list of this thread's heaps - pub segments: SegmentsTld, -} - -impl Tld { - pub fn new() -> Tld { - Tld { - heartbeat: Cell::new(0), - recurse: Cell::new(false), - heap_backing: Cell::new(ptr::null_mut()), - heaps: Cell::new(ptr::null_mut()), - segments: SegmentsTld::new(), - } - } -} - -/// `mi_heap_t`. Shared across threads only through `thread_delayed_free` -/// (atomic); everything else is owner-thread-only. -#[repr(C)] -pub struct Heap { - pub tld: Cell<*mut Tld>, - /// Exposed-address head of the delayed-free list (`*mut Block`). - pub thread_delayed_free: AtomicUsize, - pub thread_id: Cell, - pub cookie: Cell, - pub keys: [Cell; 2], - pub page_count: Cell, - pub page_retired_min: Cell, - pub page_retired_max: Cell, - pub pages_full_size: Cell, - pub generic_count: Cell, - pub generic_collect_count: Cell, - pub next: Cell<*mut Heap>, - pub no_reclaim: Cell, - pub tag: Cell, - /// Preferred/required arena (`mi_heap_t.arena_id`; 0 = any). - pub arena_id: Cell, - pub pages_free_direct: [Cell<*mut Page>; PAGES_DIRECT], - pub pages: [PageQueue; BIN_FULL + 1], -} - -// SAFETY: cross-thread access is restricted to `thread_delayed_free` and the -// immutable identity fields, mirroring mimalloc's discipline. -unsafe impl Sync for Heap {} - -/// The static empty page that `pages_free_direct` points at when a bin has -/// no page: its `free` list is null so the fast path falls into the generic -/// path without a branch. -pub static EMPTY_PAGE: Page = Page::EMPTY; - -#[inline(always)] -pub fn empty_page_ptr() -> *mut Page { - ptr::from_ref(&EMPTY_PAGE).cast_mut() -} - -impl Heap { - /// An uninitialized heap with properly sized queues (`_mi_heap_empty`). - pub fn new_empty() -> Heap { - Heap { - tld: Cell::new(ptr::null_mut()), - thread_delayed_free: AtomicUsize::new(0), - thread_id: Cell::new(0), - cookie: Cell::new(0), - keys: [Cell::new(0), Cell::new(0)], - page_count: Cell::new(0), - page_retired_min: Cell::new(BIN_FULL), - page_retired_max: Cell::new(0), - pages_full_size: Cell::new(0), - generic_count: Cell::new(0), - generic_collect_count: Cell::new(0), - next: Cell::new(ptr::null_mut()), - no_reclaim: Cell::new(false), - tag: Cell::new(0), - arena_id: Cell::new(0), - pages_free_direct: [const { Cell::new(ptr::null_mut()) }; PAGES_DIRECT], - pages: core::array::from_fn(|bin| PageQueue { - first: Cell::new(ptr::null_mut()), - last: Cell::new(ptr::null_mut()), - block_size: BIN_WSIZE[bin] * PTR_SIZE, - }), - } - } - - pub fn init_direct_pages(&self) { - for slot in &self.pages_free_direct { - slot.set(empty_page_ptr()); - } - } - - #[inline(always)] - pub fn segments_tld(&self) -> &SegmentsTld { - // SAFETY: tld outlives its heaps. - unsafe { &(*self.tld.get()).segments } - } - - #[inline(always)] - pub fn is_initialized(&self) -> bool { - !self.tld.get().is_null() - } - - /// `mi_page_queue` (by size). - #[inline(always)] - pub fn page_queue(&self, size: usize) -> &PageQueue { - &self.pages[Bin::from_size(size).index()] - } - - /// `mi_page_bin` + `mi_heap_page_queue_of`. - pub fn page_queue_of(&self, page: &Page) -> &PageQueue { - let bin = if page.in_full() { - Bin::FULL - } else if page.is_huge.get() { - Bin::HUGE - } else { - Bin::from_size(page.block_size()) - }; - &self.pages[bin.index()] - } - - /// `mi_page_set_in_full` (maintains `pages_full_size`). - pub fn page_set_in_full(&self, page: &Page, in_full: bool) { - if page.in_full() != in_full { - let size = page.capacity.get() as usize * page.block_size(); - if in_full { - self.pages_full_size.set(self.pages_full_size.get() + size); - } else { - self.pages_full_size.set(self.pages_full_size.get() - size); - } - } - page.set_in_full(in_full); - } - - /// `mi_heap_queue_first_update`: keep `pages_free_direct` in sync after - /// the first page of a small queue changed. - pub fn queue_first_update(&self, pq: &PageQueue) { - let size = pq.block_size; - if size > SMALL_SIZE_MAX { - return; - } - let mut page = pq.first.get(); - if page.is_null() { - page = empty_page_ptr(); - } - let idx = wsize_from_size(size); - if self.pages_free_direct[idx].get() == page { - return; - } - let start = if idx <= 1 { - 0 - } else { - // Skip back over queues that map to the same bin. - let bin = Bin::from_size(size); - let pq_index = - (ptr::from_ref(pq).addr() - self.pages.as_ptr().addr()) / size_of::(); - let mut prev = pq_index - 1; - while prev > 0 && bin == Bin::from_size(self.pages[prev].block_size) { - prev -= 1; - } - (1 + wsize_from_size(self.pages[prev].block_size)).min(idx) - }; - for slot in &self.pages_free_direct[start..=idx] { - slot.set(page); - } - } - - /// `mi_page_queue_remove` - pub fn page_queue_remove(&self, pq: &PageQueue, page: &Page) { - // SAFETY: queue nodes are live pages of this heap. - unsafe { - if let Some(prev) = page.prev.get().as_ref() { - prev.next.set(page.next.get()); - } - if let Some(next) = page.next.get().as_ref() { - next.prev.set(page.prev.get()); - } - } - let page_ptr = ptr::from_ref(page).cast_mut(); - if page_ptr == pq.last.get() { - pq.last.set(page.prev.get()); - } - if page_ptr == pq.first.get() { - pq.first.set(page.next.get()); - self.queue_first_update(pq); - } - self.page_count.set(self.page_count.get() - 1); - page.next.set(ptr::null_mut()); - page.prev.set(ptr::null_mut()); - self.page_set_in_full(page, false); - } - - /// `mi_page_queue_push` - pub fn page_queue_push(&self, pq: &PageQueue, page: &Page) { - let page_ptr = ptr::from_ref(page).cast_mut(); - self.page_set_in_full(page, pq.is_full()); - page.next.set(pq.first.get()); - page.prev.set(ptr::null_mut()); - // SAFETY: queue nodes are live pages of this heap. - match unsafe { pq.first.get().as_ref() } { - Some(first) => { - first.prev.set(page_ptr); - pq.first.set(page_ptr); - } - None => { - pq.first.set(page_ptr); - pq.last.set(page_ptr); - } - } - self.queue_first_update(pq); - self.page_count.set(self.page_count.get() + 1); - } - - /// `mi_page_queue_move_to_front` - pub fn page_queue_move_to_front(&self, pq: &PageQueue, page: &Page) { - if pq.first.get() == ptr::from_ref(page).cast_mut() { - return; - } - self.page_queue_remove(pq, page); - self.page_queue_push(pq, page); - } - - /// `mi_page_queue_enqueue_from` (always at the end, like mimalloc). - pub fn page_queue_enqueue_from(&self, to: &PageQueue, from: &PageQueue, page: &Page) { - let page_ptr = ptr::from_ref(page).cast_mut(); - // delete from `from` - // SAFETY: queue nodes are live pages of this heap. - unsafe { - if let Some(prev) = page.prev.get().as_ref() { - prev.next.set(page.next.get()); - } - if let Some(next) = page.next.get().as_ref() { - next.prev.set(page.prev.get()); - } - } - if page_ptr == from.last.get() { - from.last.set(page.prev.get()); - } - if page_ptr == from.first.get() { - from.first.set(page.next.get()); - self.queue_first_update(from); - } - // insert at the end of `to` - page.prev.set(to.last.get()); - page.next.set(ptr::null_mut()); - // SAFETY: queue nodes are live pages of this heap. - match unsafe { to.last.get().as_ref() } { - Some(last) => { - last.next.set(page_ptr); - to.last.set(page_ptr); - } - None => { - to.first.set(page_ptr); - to.last.set(page_ptr); - self.queue_first_update(to); - } - } - self.page_set_in_full(page, to.is_full()); - } - - /// The heap on this thread with the given tag (`_mi_heap_by_tag`). - pub fn by_tag(&self, tag: u8) -> Option<&Heap> { - if self.tag.get() == tag { - return Some(self); - } - // SAFETY: heaps list nodes are live heaps of this thread. - unsafe { - let mut h = (*self.tld.get()).heaps.get(); - while let Some(heap) = h.as_ref() { - if heap.tag.get() == tag { - return Some(heap); - } - h = heap.next.get(); - } - } - None - } -} - -/// Direct-fit page for very small sizes (`_mi_heap_get_free_small_page`). -#[inline(always)] -pub fn heap_get_free_small_page(heap: &Heap, size: usize) -> *mut Page { - let idx = wsize_from_size(size); - debug_assert!(idx < PAGES_DIRECT); - heap.pages_free_direct[idx].get() -} diff --git a/vendor/rimalloc/src/heap_ops.rs b/vendor/rimalloc/src/heap_ops.rs deleted file mode 100644 index f65c9338..00000000 --- a/vendor/rimalloc/src/heap_ops.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Heap lifetime operations (port of `heap.c`): collect, delete, destroy. - -use core::ptr; -use core::sync::atomic::Ordering; - -use crate::constants::*; -use crate::heap::{Heap, PageQueue}; -use crate::page::{Delayed, Page}; -use crate::page_ops; - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Collect { - Normal, - Force, - Abandon, -} - -/// `mi_heap_visit_pages`: visit all pages (the visitor may free the page). -/// Returns false if the visitor broke out. -pub fn heap_visit_pages(heap: &Heap, mut f: impl FnMut(&Heap, &PageQueue, &Page) -> bool) -> bool { - if heap.page_count.get() == 0 { - return true; - } - for pq in &heap.pages { - let mut p = pq.first.get(); - // SAFETY: queue nodes are live pages; we read `next` before visiting - // since the visitor may free the page. - while let Some(page) = unsafe { p.as_ref() } { - let next = page.next.get(); - if !f(heap, pq, page) { - return false; - } - p = next; - } - } - true -} - -/// `mi_heap_collect_ex` -pub fn heap_collect_ex(heap: &Heap, collect: Collect) { - if !heap.is_initialized() { - return; - } - let force = collect >= Collect::Force; - crate::hooks::deferred_free(heap, force); - - // The backing heap reclaims all abandoned segments on a forced collect - // (end-of-program): if all memory is freed by now, all segments free too. - if collect == Collect::Force && heap_is_backing(heap) && !heap.no_reclaim.get() { - crate::segment::reclaim_all(heap, heap.segments_tld()); - } - - // If abandoning, mark all pages to no longer add to delayed_free. - if collect == Collect::Abandon { - heap_visit_pages(heap, |_, _, page| { - page_ops::page_use_delayed_free(page, Delayed::NeverDelayedFree, false); - true - }); - } - - // Free all current thread delayed blocks (after this, no more - // thread-delayed references into our pages if abandoning). - page_ops::heap_delayed_free_all(heap); - - // Collect retired pages. - page_ops::heap_collect_retired(heap, force); - - // Collect all pages owned by this thread. - heap_visit_pages(heap, |_, pq, page| { - page_ops::page_free_collect(page, collect >= Collect::Force); - if collect == Collect::Force { - page.segment().try_purge(true); - } - if page.all_free() { - page_ops::page_free(page, pq, collect >= Collect::Force); - } else if collect == Collect::Abandon { - page_ops::page_abandon(page, pq); - } - true - }); - - // Collect abandoned segments. - crate::segment::abandoned_collect(heap, collect == Collect::Force, heap.segments_tld()); -} - -pub fn heap_collect(heap: &Heap, force: bool) { - heap_collect_ex( - heap, - if force { - Collect::Force - } else { - Collect::Normal - }, - ); -} - -pub fn heap_collect_abandon(heap: &Heap) { - heap_collect_ex(heap, Collect::Abandon); -} - -/// `mi_heap_reset_pages` -fn heap_reset_pages(heap: &Heap) { - heap.init_direct_pages(); - for pq in &heap.pages { - pq.first.set(ptr::null_mut()); - pq.last.set(ptr::null_mut()); - } - heap.page_count.set(0); - heap.page_retired_min.set(BIN_FULL); - heap.page_retired_max.set(0); - heap.pages_full_size.set(0); -} - -/// `mi_heap_free`: free the heap struct itself (allocated on the backing heap). -fn heap_free(heap: &Heap) { - if heap_is_backing(heap) { - return; // dont free the backing heap - } - // Remove from the thread-local heaps list. - // SAFETY: tld and the heaps list are owned by this thread. - unsafe { - let tld = &*heap.tld.get(); - let mut prev: *mut Heap = ptr::null_mut(); - let mut curr = tld.heaps.get(); - while !curr.is_null() && curr != ptr::from_ref(heap).cast_mut() { - prev = curr; - curr = (*curr).next.get(); - } - if !curr.is_null() { - match prev.as_ref() { - Some(prev) => prev.next.set((*curr).next.get()), - None => tld.heaps.set((*curr).next.get()), - } - } - } - heap.tld.set(ptr::null_mut()); - crate::free::free(ptr::from_ref(heap).cast_mut().cast()); -} - -pub fn heap_is_backing(heap: &Heap) -> bool { - // SAFETY: tld is owned by this thread. - unsafe { (*heap.tld.get()).heap_backing.get() == ptr::from_ref(heap).cast_mut() } -} - -/// `_mi_heap_init` for a fresh (non-backing) heap. -pub fn heap_init( - heap: &Heap, - tld: *mut crate::heap::Tld, - no_reclaim: bool, - tag: u8, - arena_id: usize, -) { - heap.tld.set(tld); - heap.thread_id.set(crate::init::thread_id()); - heap.cookie.set(crate::init::random_next() | 1); - heap.keys[0].set(crate::init::random_next()); - heap.keys[1].set(crate::init::random_next()); - heap.no_reclaim.set(no_reclaim); - heap.tag.set(tag); - heap.arena_id.set(arena_id); - heap.init_direct_pages(); - // push on the thread-local heaps list - // SAFETY: tld is owned by this thread. - unsafe { - heap.next.set((*tld).heaps.get()); - (*tld).heaps.set(ptr::from_ref(heap).cast_mut()); - } -} - -/// `mi_heap_new_ex` -pub fn heap_new(allow_destroy: bool, tag: u8, arena_id: usize) -> Option<&'static Heap> { - let bheap = crate::init::heap_default(); - let p = crate::alloc::heap_malloc_zero(bheap, size_of::(), false)?; - let heap_ptr = p.as_ptr().cast::(); - // SAFETY: freshly allocated, sized and aligned for a Heap. - unsafe { heap_ptr.write(Heap::new_empty()) }; - // SAFETY: just initialized above. - let heap = unsafe { &*heap_ptr }; - heap_init(heap, bheap.tld.get(), allow_destroy, tag, arena_id); - Some(heap) -} - -/// `_mi_heap_page_destroy` -fn heap_page_destroy(heap: &Heap, page: &Page) { - page_ops::page_use_delayed_free(page, Delayed::NeverDelayedFree, false); - // pretend it is all free now - page.used.set(0); - page.next.set(ptr::null_mut()); - page.prev.set(ptr::null_mut()); - page.set_heap(ptr::null_mut()); - page.segment().page_free(page, false, heap.segments_tld()); -} - -/// `_mi_heap_destroy_pages` -pub fn heap_destroy_pages(heap: &Heap) { - heap_visit_pages(heap, |heap, _, page| { - heap_page_destroy(heap, page); - true - }); - heap_reset_pages(heap); -} - -/// `mi_heap_destroy`: free all pages without freeing individual blocks. -pub fn heap_destroy(heap: &Heap) { - if !heap.is_initialized() { - return; - } - if !heap.no_reclaim.get() { - // unsafe to destroy a heap that may contain reclaimed pages - heap_delete(heap); - } else { - heap_destroy_pages(heap); - heap_free(heap); - } -} - -/// `mi_heap_absorb`: move all pages of `from` into `heap`. -fn heap_absorb(heap: &Heap, from: &Heap) { - if from.page_count.get() == 0 { - return; - } - page_ops::heap_delayed_free_partial(from); - for (pq, append) in heap.pages.iter().zip(&from.pages) { - // set new heap field on every page; spins out DELAYED_FREEING - let mut p = append.first.get(); - let mut count = 0; - // SAFETY: queue nodes are live pages of `from`. - while let Some(page) = unsafe { p.as_ref() } { - page.xheap - .store(ptr::from_ref(heap).expose_provenance(), Ordering::Release); - page_ops::page_use_delayed_free(page, Delayed::UseDelayedFree, false); - if page.in_full() { - // transfer the full-queue size accounting along with the page - // (`from`'s counter is wiped by heap_reset_pages below) - let size = page.capacity.get() as usize * page.block_size(); - heap.pages_full_size.set(heap.pages_full_size.get() + size); - } - count += 1; - p = page.next.get(); - } - // append the queue - if append.first.get().is_null() { - continue; - } - // SAFETY: live pages. - unsafe { - if pq.last.get().is_null() { - pq.first.set(append.first.get()); - pq.last.set(append.last.get()); - heap.queue_first_update(pq); - } else { - (*pq.last.get()).next.set(append.first.get()); - (*append.first.get()).prev.set(pq.last.get()); - pq.last.set(append.last.get()); - } - } - append.first.set(ptr::null_mut()); - append.last.set(ptr::null_mut()); - heap.page_count.set(heap.page_count.get() + count); - from.page_count.set(from.page_count.get() - count); - } - debug_assert!(from.page_count.get() == 0); - page_ops::heap_delayed_free_all(from); - heap_reset_pages(from); -} - -/// `mi_heap_delete`: delete a heap, transferring live pages to the backing -/// heap (or abandoning them for a backing heap). -pub fn heap_delete(heap: &Heap) { - if !heap.is_initialized() { - return; - } - // SAFETY: tld is owned by this thread. - let bheap = unsafe { &*(*heap.tld.get()).heap_backing.get() }; - if !ptr::eq(bheap, heap) && bheap.tag.get() == heap.tag.get() { - heap_absorb(bheap, heap); - } else { - heap_collect_abandon(heap); - } - heap_free(heap); -} diff --git a/vendor/rimalloc/src/hooks.rs b/vendor/rimalloc/src/hooks.rs deleted file mode 100644 index 8948528a..00000000 --- a/vendor/rimalloc/src/hooks.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! User-registered callbacks (`mi_register_deferred_free`, -//! `mi_register_output`, `mi_register_error`). - -use core::ffi::c_void; -use core::sync::atomic::{AtomicPtr, Ordering}; - -/// `mi_deferred_free_fun`: called when the allocator runs low on free -/// blocks, so the program can free cached objects. `force` is set on -/// forced collection; `heartbeat` is a deterministic monotonic count. -pub type DeferredFreeFun = extern "C" fn(force: bool, heartbeat: u64, arg: *mut c_void); -/// `mi_output_fun`: receives diagnostic output (stats, warnings). -pub type OutputFun = extern "C" fn(msg: *const u8, len: usize, arg: *mut c_void); -/// `mi_error_fun`: receives error codes (EFAULT, ENOMEM, EOVERFLOW, EINVAL). -pub type ErrorFun = extern "C" fn(code: i32, arg: *mut c_void); - -// Function pointers stored as data pointers (provenance preserved). -static DEFERRED_FREE: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); -static DEFERRED_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); -static OUTPUT: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); -static OUTPUT_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); -static ERROR: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); -static ERROR_ARG: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); - -/// `mi_register_deferred_free` (pass `None` to unregister). -pub fn register_deferred_free(f: Option, arg: *mut c_void) { - DEFERRED_ARG.store(arg, Ordering::Release); - DEFERRED_FREE.store( - f.map_or(core::ptr::null_mut(), |f| f as *mut ()), - Ordering::Release, - ); -} - -/// `mi_register_output` -pub fn register_output(f: Option, arg: *mut c_void) { - OUTPUT_ARG.store(arg, Ordering::Release); - OUTPUT.store( - f.map_or(core::ptr::null_mut(), |f| f as *mut ()), - Ordering::Release, - ); -} - -/// `mi_register_error` -pub fn register_error(f: Option, arg: *mut c_void) { - ERROR_ARG.store(arg, Ordering::Release); - ERROR.store( - f.map_or(core::ptr::null_mut(), |f| f as *mut ()), - Ordering::Release, - ); -} - -/// `_mi_deferred_free`: bump the heartbeat and call the user hook (guarded -/// against recursion through `tld.recurse`). -pub fn deferred_free(heap: &crate::heap::Heap, force: bool) { - // SAFETY: tld outlives its heaps; owner-thread access. - let tld = unsafe { &*heap.tld.get() }; - tld.heartbeat.set(tld.heartbeat.get() + 1); - let f = DEFERRED_FREE.load(Ordering::Acquire); - if !f.is_null() && !tld.recurse.get() { - tld.recurse.set(true); - // SAFETY: registered via register_deferred_free with this signature. - let f: DeferredFreeFun = unsafe { core::mem::transmute::<*mut (), DeferredFreeFun>(f) }; - f( - force, - tld.heartbeat.get(), - DEFERRED_ARG.load(Ordering::Acquire), - ); - tld.recurse.set(false); - } -} - -/// Route a diagnostic message to the registered output hook (or stderr). -pub fn output(msg: &str) { - let f = OUTPUT.load(Ordering::Acquire); - if !f.is_null() { - // SAFETY: registered via register_output with this signature. - let f: OutputFun = unsafe { core::mem::transmute::<*mut (), OutputFun>(f) }; - f(msg.as_ptr(), msg.len(), OUTPUT_ARG.load(Ordering::Acquire)); - } else { - #[cfg(not(windows))] - // SAFETY: plain write(2) to stderr. - unsafe { libc::write(2, msg.as_ptr().cast(), msg.len()) }; - #[cfg(windows)] - // SAFETY: WriteFile to stderr handle. - unsafe { - const STD_ERROR_HANDLE: u32 = u32::MAX - 12; // -12 - let handle = GetStdHandle(STD_ERROR_HANDLE); - if !handle.is_null() && handle != winapi_invalid_handle() { - let mut written: u32 = 0; - WriteFile( - handle, - msg.as_ptr().cast(), - msg.len().try_into().unwrap_or(u32::MAX), - &mut written, - core::ptr::null_mut(), - ); - } - } - } -} - -#[cfg(windows)] -unsafe extern "system" { - fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void; - fn WriteFile( - hFile: *mut core::ffi::c_void, - lpBuffer: *const core::ffi::c_void, - nNumberOfBytesToWrite: u32, - lpNumberOfBytesWritten: *mut u32, - lpOverlapped: *mut core::ffi::c_void, - ) -> i32; -} - -#[cfg(windows)] -fn winapi_invalid_handle() -> *mut core::ffi::c_void { - core::ptr::with_exposed_provenance_mut(!0isize as usize) // INVALID_HANDLE_VALUE -} - -/// Report an error code to the registered error hook (after the message -/// went to the output hook). -pub fn error(code: i32) { - let f = ERROR.load(Ordering::Acquire); - if !f.is_null() { - // SAFETY: registered via register_error with this signature. - let f: ErrorFun = unsafe { core::mem::transmute::<*mut (), ErrorFun>(f) }; - f(code, ERROR_ARG.load(Ordering::Acquire)); - } -} diff --git a/vendor/rimalloc/src/init.rs b/vendor/rimalloc/src/init.rs deleted file mode 100644 index f63366d8..00000000 --- a/vendor/rimalloc/src/init.rs +++ /dev/null @@ -1,624 +0,0 @@ -//! Process and thread initialization (port of `init.c`): the thread-local -//! default heap, lazy thread setup, and thread-exit cleanup via a pthread -//! key destructor (Unix) or FLS callback (Windows). - -use core::cell::Cell; -use core::ptr; -use core::sync::atomic::{AtomicUsize, Ordering}; - -use crate::heap::{Heap, Tld}; -use crate::os::OsMem; - -/// Unique, cheap thread id. `pthread_self` rather than a thread-local's -/// address: macOS tears down and re-instantiates `#[thread_local]` storage -/// before pthread TSD destructors run, so a TLS address changes identity -/// mid-teardown and exit-time frees would take the cross-thread path -/// (leaking blocks onto the dying heap's delayed list). -/// -/// On Apple arm64 this reads pthread TSD slot 0 (the pthread self pointer) -/// straight off the thread register, like C mimalloc's -/// `_mi_prim_thread_id`; it stays valid through TLS teardown. -#[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] -#[inline(always)] -pub fn thread_id() -> usize { - let tcb: *const usize; - // SAFETY: reads the read-only thread register; no memory effects. - unsafe { - core::arch::asm!( - "mrs {tcb}, tpidrro_el0", - "bic {tcb}, {tcb}, #7", - tcb = out(reg) tcb, - options(nomem, nostack, pure, preserves_flags) - ); - } - // SAFETY: slot 0 of the TSD block is `pthread_self`, always readable. - unsafe { *tcb } -} - -#[cfg(all(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))), not(windows)))] -#[inline(always)] -pub fn thread_id() -> usize { - let t = tls::TID.get(); - if t != 0 { - return t; - } - // SAFETY: always callable; on macOS this is a thread-register read. - let t = unsafe { libc::pthread_self() as usize }; - tls::TID.set(t); - t -} - -#[cfg(windows)] -#[inline(always)] -pub fn thread_id() -> usize { - let t = tls::TID.get(); - if t != 0 { - return t; - } - // SAFETY: always callable. - let t = unsafe { GetCurrentThreadId() as usize }; - tls::TID.set(t); - t -} - -// The static empty heap: the initial default for every thread, so the -// malloc fast path needs no init check (its pages have no free blocks, so -// allocation falls into `malloc_generic` which initializes the thread). -struct StaticHeap(Heap); -// SAFETY: read-only use across threads (its lists stay empty), except for -// the main-thread heap which is owned by the main thread. -unsafe impl Sync for StaticHeap {} - -static EMPTY_HEAP: StaticHeap = StaticHeap(heap_empty_const( - ptr::from_ref(&crate::heap::EMPTY_PAGE).cast_mut(), -)); - -const fn heap_empty_const(empty_page: *mut crate::page::Page) -> Heap { - use crate::bins::BIN_WSIZE; - use crate::constants::{BIN_FULL, PAGES_DIRECT, PTR_SIZE}; - use crate::heap::PageQueue; - let mut pages = [const { - PageQueue { - first: Cell::new(ptr::null_mut()), - last: Cell::new(ptr::null_mut()), - block_size: 0, - } - }; BIN_FULL + 1]; - let mut i = 0; - while i <= BIN_FULL { - pages[i].block_size = BIN_WSIZE[i] * PTR_SIZE; - i += 1; - } - Heap { - tld: Cell::new(ptr::null_mut()), - thread_delayed_free: core::sync::atomic::AtomicUsize::new(0), - thread_id: Cell::new(0), - cookie: Cell::new(0), - keys: [Cell::new(0), Cell::new(0)], - page_count: Cell::new(0), - page_retired_min: Cell::new(BIN_FULL), - page_retired_max: Cell::new(0), - pages_full_size: Cell::new(0), - generic_count: Cell::new(0), - generic_collect_count: Cell::new(0), - next: Cell::new(ptr::null_mut()), - no_reclaim: Cell::new(false), - tag: Cell::new(0), - arena_id: Cell::new(0), - // SAFETY: Cell is repr(transparent) over T. - pages_free_direct: unsafe { - core::mem::transmute::< - [*mut crate::page::Page; PAGES_DIRECT], - [Cell<*mut crate::page::Page>; PAGES_DIRECT], - >([empty_page; PAGES_DIRECT]) - }, - pages, - } -} - -#[inline(always)] -fn empty_heap() -> *mut Heap { - ptr::from_ref(&EMPTY_HEAP.0).cast_mut() -} - -/// Direct pthread TSD slot for the default heap on Apple arm64: the same -/// unused slot C mimalloc claims (`MI_TLS_SLOT` 89, `__PTK_FRAMEWORK_OLDGC_KEY9`). -/// Mach-O `thread_local!`/`#[thread_local]` accesses go through a -/// `_tlv_get_addr` call on every malloc/free; this is a 3-instruction load -/// off the read-only thread register instead, and (unlike TLV storage) it -/// stays valid through TLS teardown at thread exit. -#[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] -mod tsd { - use super::*; - - const HEAP_SLOT: usize = 89; - - #[inline(always)] - fn tsd_base() -> *mut usize { - let tcb: *mut usize; - // SAFETY: reads the read-only thread register; no memory effects. - unsafe { - core::arch::asm!( - "mrs {tcb}, tpidrro_el0", - "bic {tcb}, {tcb}, #7", - tcb = out(reg) tcb, - options(nomem, nostack, pure, preserves_flags) - ); - } - tcb - } - - /// Relaxed atomics rather than plain loads/stores: the slot is - /// logically thread-private, but it lives inside the pthread struct, - /// which libpthread recycles across threads at the same address (reuse - /// is ordered inside libpthread, invisibly to TSan). The dying thread's - /// final store (`thread_done` via the TSD destructor) would otherwise - /// appear to race the first read of a later thread occupying the - /// recycled struct. Same single ldr/str on arm64; zero cost. - pub struct HeapSlot; - impl HeapSlot { - #[inline(always)] - pub fn get(&self) -> *mut Heap { - // SAFETY: slot 89 of this thread's TSD block is reserved for the - // allocator (as in C mimalloc on Apple); it is zero on fresh - // threads, which maps to the static empty heap. - let slot = unsafe { AtomicUsize::from_ptr(tsd_base().add(HEAP_SLOT)) }; - let p = slot.load(Ordering::Relaxed) as *mut Heap; - if p.is_null() { empty_heap() } else { p } - } - #[inline(always)] - pub fn set(&self, v: *mut Heap) { - // SAFETY: as in `get`; only this thread writes its own slot. - let slot = unsafe { AtomicUsize::from_ptr(tsd_base().add(HEAP_SLOT)) }; - slot.store(v as usize, Ordering::Relaxed); - } - } - pub static DEFAULT_HEAP: HeapSlot = HeapSlot; -} - -/// The allocator's three thread-local slots. Stable Rust uses -/// const-initialized `thread_local!` (correct everywhere, ~1ns per access -/// through `LocalKey`); the opt-in `nightly` feature uses raw -/// `#[thread_local]` statics for direct TLS loads on the malloc/free fast -/// paths. On Apple arm64 the default-heap slot instead lives in a pthread -/// TSD slot (see `tsd` above). -#[cfg(not(feature = "nightly"))] -mod tls { - use super::*; - - pub struct Slot(&'static std::thread::LocalKey>); - impl Slot { - #[inline(always)] - pub fn get(&self) -> T { - self.0.with(Cell::get) - } - #[inline(always)] - pub fn set(&self, v: T) { - self.0.with(|c| c.set(v)) - } - } - - thread_local! { - static TID_KEY: Cell = const { Cell::new(0) }; - static RNG_KEY: Cell = const { Cell::new(0) }; - } - #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] - thread_local! { - static HEAP_KEY: Cell<*mut Heap> = - const { Cell::new((&raw const EMPTY_HEAP.0).cast_mut()) }; - } - #[cfg_attr( - all(target_vendor = "apple", target_arch = "aarch64", not(miri)), - allow(dead_code) - )] - pub static TID: Slot = Slot(&TID_KEY); - #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] - pub static DEFAULT_HEAP: Slot<*mut Heap> = Slot(&HEAP_KEY); - #[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] - pub use super::tsd::DEFAULT_HEAP; - pub static RNG: Slot = Slot(&RNG_KEY); -} - -#[cfg(feature = "nightly")] -mod tls { - use super::*; - - #[thread_local] - static TID_RAW: Cell = Cell::new(0); - #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] - #[thread_local] - static HEAP_RAW: Cell<*mut Heap> = Cell::new((&raw const EMPTY_HEAP.0).cast_mut()); - #[thread_local] - static RNG_RAW: Cell = Cell::new(0); - - macro_rules! slot { - ($name:ident, $ty:ty, $raw:ident, $slot:ident) => { - pub struct $name; - impl $name { - #[inline(always)] - pub fn get(&self) -> $ty { - $raw.get() - } - #[inline(always)] - pub fn set(&self, v: $ty) { - $raw.set(v) - } - } - pub static $slot: $name = $name; - }; - } - #[cfg_attr( - all(target_vendor = "apple", target_arch = "aarch64", not(miri)), - allow(dead_code) - )] - mod tid { - use super::*; - slot!(TidSlot, usize, TID_RAW, TID); - } - #[cfg_attr( - all(target_vendor = "apple", target_arch = "aarch64", not(miri)), - allow(unused_imports) - )] - pub use tid::TID; - #[cfg(not(all(target_vendor = "apple", target_arch = "aarch64", not(miri))))] - slot!(HeapSlot, *mut Heap, HEAP_RAW, DEFAULT_HEAP); - #[cfg(all(target_vendor = "apple", target_arch = "aarch64", not(miri)))] - pub use super::tsd::DEFAULT_HEAP; - slot!(RngSlot, u64, RNG_RAW, RNG); -} - -#[inline(always)] -pub fn heap_default_unchecked() -> &'static Heap { - // SAFETY: always the static empty heap or this thread's live heap. - unsafe { &*tls::DEFAULT_HEAP.get() } -} - -#[inline(always)] -pub fn thread_is_initialized() -> bool { - tls::DEFAULT_HEAP.get() != empty_heap() -} - -/// `mi_heap_get_default`: initializes the thread if needed. -#[inline] -pub fn heap_default() -> &'static Heap { - if !thread_is_initialized() { - thread_init(); - } - heap_default_unchecked() -} - -pub fn heap_set_default(heap: &Heap) -> &'static Heap { - let old = heap_default(); - tls::DEFAULT_HEAP.set(ptr::from_ref(heap).cast_mut()); - old -} - -// --------------------------------------------------------------------------- -// Thread metadata allocation (OS-backed so it does not recurse into malloc) -// --------------------------------------------------------------------------- - -#[repr(C)] -struct ThreadData { - heap: Heap, // must be first (cast in thread_done) - tld: Tld, - mem: OsMem, -} - -static THREAD_COUNT: AtomicUsize = AtomicUsize::new(0); - -pub fn current_thread_count() -> usize { - THREAD_COUNT.load(Ordering::Relaxed).max(1) -} - -// Simple wyrand-style RNG for cookies/keys (free lists are not encoded). -pub fn random_next() -> usize { - let mut s = tls::RNG.get(); - if s == 0 { - s = thread_id() as u64 ^ 0x9e37_79b9_7f4a_7c15 ^ (options_entropy() << 1) | 1; - } - s = s.wrapping_add(0xa076_1d64_78bd_642f); - tls::RNG.set(s); - let t = (s as u128).wrapping_mul((s ^ 0xe703_7ed1_a0b4_28db) as u128); - ((t >> 64) ^ t) as usize -} - -#[cfg(not(windows))] -fn options_entropy() -> u64 { - let mut ts = libc::timespec { - tv_sec: 0, - tv_nsec: 0, - }; - // SAFETY: valid out-pointer. - unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; - ts.tv_nsec as u64 -} - -#[cfg(windows)] -fn options_entropy() -> u64 { - let mut counter: i64 = 0; - // SAFETY: valid out-pointer. - unsafe { QueryPerformanceCounter(&mut counter) }; - counter as u64 -} - -// Cache of dead ThreadData regions (`mi_thread_data_cache`): thread churn -// must not mmap/munmap per thread. Nodes are written into the dead regions. -struct TdNode { - next: *mut TdNode, - mem: OsMem, -} -struct TdCache { - lock: core::sync::atomic::AtomicBool, - first: Cell<*mut TdNode>, - count: AtomicUsize, -} -// SAFETY: `first` only accessed under `lock`. -unsafe impl Sync for TdCache {} -static TD_CACHE: TdCache = TdCache { - lock: core::sync::atomic::AtomicBool::new(false), - first: Cell::new(ptr::null_mut()), - count: AtomicUsize::new(0), -}; -const TD_CACHE_MAX: usize = 32; - -fn td_locked(f: impl FnOnce() -> R) -> R { - crate::sync::spin_locked(&TD_CACHE.lock, f) -} - -fn td_cache_pop() -> Option<(*mut u8, OsMem)> { - if TD_CACHE.count.load(Ordering::Relaxed) == 0 { - return None; - } - let mem = td_locked(|| { - // SAFETY: nodes are owned by the cache while linked (lock held). - unsafe { - let node = TD_CACHE.first.get().as_ref()?; - TD_CACHE.first.set(node.next); - Some(node.mem) - } - })?; - TD_CACHE.count.fetch_sub(1, Ordering::Relaxed); - Some((mem.base, mem)) -} - -fn td_cache_push(mem: OsMem) -> bool { - if TD_CACHE.count.load(Ordering::Relaxed) >= TD_CACHE_MAX { - return false; - } - // SAFETY: dead, fully-committed region; node lives at the tail, away - // from the Heap header which protected references may still cover. - let node = unsafe { crate::os::tail_node::(&mem) }; - td_locked(|| { - // SAFETY: dead region owned by the cache. - unsafe { - node.write(TdNode { - next: TD_CACHE.first.get(), - mem, - }) - }; - TD_CACHE.first.set(node); - }); - TD_CACHE.count.fetch_add(1, Ordering::Relaxed); - true -} - -// --------------------------------------------------------------------------- -// Thread init / done -// --------------------------------------------------------------------------- - -static THREAD_KEY: AtomicUsize = AtomicUsize::new(usize::MAX); - -#[cfg(not(windows))] -pub(crate) fn td_cache_lock() -> &'static core::sync::atomic::AtomicBool { - &TD_CACHE.lock -} - -// --------------------------------------------------------------------------- -// Unix: pthread TSD thread-exit callback -// --------------------------------------------------------------------------- - -#[cfg(not(windows))] -/// Register the `pthread_atfork` handlers that hold every allocator lock -/// across `fork()`. This MUST run before any thread or fork exists, so it -/// lives in a load-time constructor (`register_fork_handlers`); doing it -/// lazily on first allocation lets a fork race a half-registered or -/// orphaned-lock state into the child. Split out so the lazy fallback (for -/// builds without a usable load-time constructor) can reuse it. -fn register_fork_handlers() { - // SAFETY: a plain libc call with three valid handler fns; the handlers - // only touch allocator spinlocks and never allocate. - unsafe { - libc::pthread_atfork( - Some(crate::os::fork_prepare), - Some(crate::os::fork_release), - Some(crate::os::fork_release), - ); - } -} - -#[cfg(not(windows))] -unsafe extern "C" fn thread_done_callback(value: *mut libc::c_void) { - // On macOS the dynamic loader tears down `#[thread_local]` storage - // before pthread TSD destructors run, so the heap arrives as the key - // value (mimalloc does the same in `prim/unix`). - if !value.is_null() { - // SAFETY: the value is this thread's backing heap, still live. - thread_done_with(unsafe { &*value.cast::() }); - } -} - -// --------------------------------------------------------------------------- -// Windows: FLS thread-exit callback -// --------------------------------------------------------------------------- - -#[cfg(windows)] -unsafe extern "system" { - fn FlsAlloc(callback: Option) -> u32; - fn FlsSetValue(fls_handle: u32, value: *mut core::ffi::c_void) -> i32; - fn GetCurrentThreadId() -> u32; - fn QueryPerformanceCounter(lpPerformanceCount: *mut i64) -> i32; -} - -#[cfg(windows)] -fn register_fork_handlers() { - // No fork on Windows. -} - -#[cfg(windows)] -unsafe extern "system" fn thread_done_callback(value: *mut core::ffi::c_void) { - if !value.is_null() { - // SAFETY: the value is this thread's backing heap, still live. - thread_done_with(unsafe { &*value.cast::() }); - } -} - -// --------------------------------------------------------------------------- - -/// Runs at image load, before `main` and before any thread or fork exists, -/// so the atfork handlers are always registered ahead of any `fork()`. The -/// body is a single libc call and never allocates, so it is safe even when -/// `rimalloc` is the `#[global_allocator]` (the allocator is not used yet). -/// -/// Excluded under `fuzzing`: cargo-fuzz's sancov/ASan linker rejects the -/// constructor's `__init_offsets` entry (`initializer pointer has no -/// target`); the fuzz harness is single-process and never forks, so the -/// lazy fallback below suffices there. -#[cfg(all(not(miri), not(fuzzing)))] -#[ctor::ctor(unsafe)] -fn fork_handler_ctor() { - register_fork_handlers(); -} - -fn ensure_process_init() { - static ONCE: AtomicUsize = AtomicUsize::new(0); - if ONCE.swap(1, Ordering::AcqRel) == 0 { - #[cfg(not(windows))] - // SAFETY: valid out-pointer and destructor fn. - unsafe { - let mut key: libc::pthread_key_t = 0; - libc::pthread_key_create(&mut key, Some(thread_done_callback)); - THREAD_KEY.store(key as usize, Ordering::Release); - } - #[cfg(windows)] - // SAFETY: registers a thread-exit callback via FLS. - unsafe { - let key = FlsAlloc(Some(thread_done_callback)); - // FLS_OUT_OF_INDEXES = 0xFFFFFFFF - if key != u32::MAX { - THREAD_KEY.store(key as usize, Ordering::Release); - } - } - // Builds without the load-time constructor (Miri's interpreter, - // fuzzing's sancov link) register the handlers lazily here. Neither - // forks, so the registration-window race the constructor closes does - // not apply. - #[cfg(any(miri, fuzzing))] - register_fork_handlers(); - } -} - -/// `mi_thread_init`: set up this thread's tld + backing heap. -pub fn thread_init() { - if thread_is_initialized() { - return; - } - ensure_process_init(); - - let size = crate::constants::align_up(size_of::(), crate::os::page_size()); - let Some((p, mem)) = - td_cache_pop().or_else(|| OsMem::alloc_aligned(size, crate::os::page_size(), true)) - else { - return; - }; - let td = p.cast::(); - // SAFETY: fresh OS memory, sized for ThreadData; we initialize all fields. - unsafe { - td.write(ThreadData { - heap: Heap::new_empty(), - tld: Tld::new(), - mem, - }); - let heap = &raw mut (*td).heap; - let tld = &raw mut (*td).tld; - (*tld).heap_backing.set(heap); - crate::heap_ops::heap_init(&*heap, tld, false, 0, 0); - tls::DEFAULT_HEAP.set(heap); - } - THREAD_COUNT.fetch_add(1, Ordering::Relaxed); - crate::stats::STATS.threads.increase(1); - - let key = THREAD_KEY.load(Ordering::Acquire); - if key != usize::MAX { - #[cfg(not(windows))] - // SAFETY: key was created in ensure_process_init; the value is the - // backing heap pointer, handed to the destructor at thread exit. - unsafe { - libc::pthread_setspecific(key as libc::pthread_key_t, tls::DEFAULT_HEAP.get().cast()) - }; - #[cfg(windows)] - // SAFETY: key was created in ensure_process_init via FlsAlloc. - unsafe { - FlsSetValue(key as u32, tls::DEFAULT_HEAP.get().cast()); - } - } -} - -/// `_mi_thread_done` -pub fn thread_done() { - if !thread_is_initialized() { - return; - } - thread_done_with(heap_default_unchecked()); -} - -/// `_mi_thread_heap_done`: tear down a thread given its (backing) heap. -/// `heap` must belong to the current thread. -fn thread_done_with(heap: &Heap) { - if !heap.is_initialized() { - return; // already done - } - tls::DEFAULT_HEAP.set(empty_heap()); - // Clear the FLS/TSD value so the destructor does not fire again on a heap - // we are about to free. - let key = THREAD_KEY.load(Ordering::Acquire); - if key != usize::MAX { - #[cfg(not(windows))] - // SAFETY: valid key. - unsafe { libc::pthread_setspecific(key as libc::pthread_key_t, ptr::null()) }; - #[cfg(windows)] - // SAFETY: valid FLS key. - unsafe { FlsSetValue(key as u32, ptr::null_mut()) }; - } - - // SAFETY: this thread's tld/heaps are live until we free them below. - unsafe { - let tld = &*heap.tld.get(); - // The raw backing pointer carries provenance over the whole - // ThreadData (set in thread_init); keep it for the final free. - let backing_ptr = tld.heap_backing.get(); - let backing = &*backing_ptr; - - // delete all non-backing heaps - let mut curr = tld.heaps.get(); - while let Some(h) = curr.as_ref() { - let next = h.next.get(); - if !ptr::eq(h, backing) { - crate::heap_ops::heap_delete(h); - } - curr = next; - } - - // abandon what is still live - crate::heap_ops::heap_collect_abandon(backing); - - // free the thread metadata (cache it for the next thread) - let td = backing_ptr.cast::(); - let mem = (*td).mem; - if !td_cache_push(mem) { - crate::os::defer_free(mem); - } - } - THREAD_COUNT.fetch_sub(1, Ordering::Relaxed); - crate::stats::STATS.threads.decrease(1); - crate::os::drain_deferred(); -} diff --git a/vendor/rimalloc/src/lib.rs b/vendor/rimalloc/src/lib.rs deleted file mode 100644 index 1c5f57bf..00000000 --- a/vendor/rimalloc/src/lib.rs +++ /dev/null @@ -1,95 +0,0 @@ -#![cfg_attr(feature = "nightly", feature(thread_local))] -//! # rimalloc -//! -//! A pure-Rust port of [mimalloc](https://github.com/microsoft/mimalloc) -//! (v2.3.2): a compact general-purpose allocator with per-thread heaps, -//! sharded free lists, and lock-free cross-thread frees. -//! -//! ``` -//! use rimalloc::Rimalloc; -//! -//! // #[global_allocator] -//! // static ALLOC: Rimalloc = Rimalloc; -//! let p = rimalloc::malloc(42); -//! assert!(rimalloc::usable_size(p) >= 42); -//! rimalloc::free(p); -//! ``` - -#![allow(clippy::missing_safety_doc)] - -mod alloc; -mod api; -mod arena; -mod bins; -pub mod bitmap; -mod constants; -mod free; -mod heap; -mod heap_ops; -pub mod hooks; -mod init; -pub mod options; -mod os; -mod page; -mod page_ops; -pub mod protocol; -mod segment; -mod stats; -mod sync; -mod visit; - -pub use api::*; -pub use bins::good_size as mi_good_size; -pub use heap::Heap; -pub use visit::{HeapArea, heap_visit_blocks}; - -// Errno constants: POSIX values are universal. -#[cfg(not(windows))] -pub use libc::{EFAULT, EINVAL, ENOMEM}; -#[cfg(windows)] -pub const EFAULT: i32 = 14; -#[cfg(windows)] -pub const EINVAL: i32 = 22; -#[cfg(windows)] -pub const ENOMEM: i32 = 12; - -/// `_mi_error_message`: route to the registered output/error hooks -/// (stderr by default) without allocating. -pub(crate) fn error(msg: &str) { - error_code(msg, EFAULT) -} - -pub(crate) fn error_code(msg: &str, code: i32) { - hooks::output("rimalloc: error: "); - hooks::output(msg); - hooks::output("\n"); - hooks::error(code); -} - -/// Bridge for the Lean conformance tests (`tests/lean_conformance.rs`): -/// exposes the exact functions the verified model mirrors, so the live -/// implementation can be pinned to the model over its whole domain. -pub mod lean_model { - /// `Bin::from_size` on a word count (what `Bins.lean::binFromWsize` models). - pub fn bin_from_wsize(wsize: usize) -> usize { - crate::bins::Bin::from_size(wsize * crate::constants::PTR_SIZE).index() - } - - /// `BIN_WSIZE` table entry (what `Bins.lean::binWsize` models). - pub fn bin_wsize(bin: usize) -> usize { - crate::bins::BIN_WSIZE[bin] - } - - /// `CommitMask::create` as raw words (what `CommitMask.lean::createLoop` - /// models). - pub fn commit_mask_create_words(idx: usize, count: usize) -> [usize; 8] { - let cm = crate::segment::CommitMask::create(idx, count); - cm.to_words() - } - - /// Page block-area start offset (what `PageStart.lean::startOffset` - /// models). - pub fn page_start_offset(block_size: usize, pstart_addr: usize, psize: usize) -> usize { - crate::segment::page_start_offset(block_size, pstart_addr, psize) - } -} diff --git a/vendor/rimalloc/src/options.rs b/vendor/rimalloc/src/options.rs deleted file mode 100644 index 88789ff9..00000000 --- a/vendor/rimalloc/src/options.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Runtime options (port of `options.c`): each option reads its -//! `MIMALLOC_*` environment variable once and is settable at runtime -//! (`mi_option_get`/`mi_option_set`). - -use core::sync::atomic::{AtomicI64, Ordering}; - -macro_rules! options { - ($($variant:ident => $name:ident: $default:expr, $env:literal;)*) => { - /// `mi_option_t` - #[derive(Clone, Copy, PartialEq, Eq, Debug)] - #[repr(usize)] - pub enum Option { - $($variant),* - } - - const COUNT: usize = [$($env),*].len(); - const DEFAULTS: [(i64, &str); COUNT] = [$(($default, $env)),*]; - // i64::MIN marks "not yet initialized from the environment". - static VALUES: [AtomicI64; COUNT] = [const { AtomicI64::new(i64::MIN) }; COUNT]; - - $( - /// Internal shorthand for this option's value. - #[allow(dead_code)] - #[inline] - pub fn $name() -> i64 { - get(Option::$variant) - } - )* - }; -} - -options! { - EagerCommit => eager_commit: 1, "MIMALLOC_EAGER_COMMIT"; - EagerCommitDelay => eager_commit_delay: 0, "MIMALLOC_EAGER_COMMIT_DELAY"; - PurgeDelay => purge_delay: 10, "MIMALLOC_PURGE_DELAY"; - PurgeExtendDelay => purge_extend_delay: 1, "MIMALLOC_PURGE_EXTEND_DELAY"; - AbandonedPagePurge => abandoned_page_purge: 0, "MIMALLOC_ABANDONED_PAGE_PURGE"; - FullPageRetain => full_page_retain: 2, "MIMALLOC_FULL_PAGE_RETAIN"; - MaxPageCandidates => max_page_candidates: 4, "MIMALLOC_MAX_PAGE_CANDIDATES"; - MaxSegmentReclaim => max_segment_reclaim: 10, "MIMALLOC_MAX_SEGMENT_RECLAIM"; - AbandonedReclaimOnFree => abandoned_reclaim_on_free: 0, "MIMALLOC_ABANDONED_RECLAIM_ON_FREE"; - TargetSegmentsPerThread => target_segments_per_thread: 0, "MIMALLOC_TARGET_SEGMENTS_PER_THREAD"; - PurgeDecommits => purge_decommits: 1, "MIMALLOC_PURGE_DECOMMITS"; - ArenaReserve => arena_reserve: 1024 * 1024, "MIMALLOC_ARENA_RESERVE"; // KiB - ShowErrors => show_errors: 0, "MIMALLOC_SHOW_ERRORS"; - ShowStats => show_stats: 0, "MIMALLOC_SHOW_STATS"; - Verbose => verbose: 0, "MIMALLOC_VERBOSE"; -} - -/// Case-insensitive comparison of `s` against a short literal, without -/// allocating (avoids `to_ascii_lowercase` → String). -fn eq_ignore_case(s: &str, lit: &str) -> bool { - s.len() == lit.len() && s.as_bytes().iter().zip(lit.as_bytes()) - .all(|(a, b)| a.eq_ignore_ascii_case(b)) -} - -fn parse(s: &str) -> std::option::Option { - if s.is_empty() || eq_ignore_case(s, "on") || eq_ignore_case(s, "yes") || eq_ignore_case(s, "true") - { - Some(1) - } else if eq_ignore_case(s, "off") || eq_ignore_case(s, "no") || eq_ignore_case(s, "false") { - Some(0) - } else { - s.parse().ok() - } -} - -/// Read an environment variable using raw OS calls that do **not** go -/// through `#[global_allocator]`. `std::env::var()` allocates a `String`, -/// which would recurse into `Rimalloc::alloc()` when called from the -/// allocation slow path. -fn read_env(env: &str) -> std::option::Option { - let mut buf = [0u8; 512]; - - #[cfg(unix)] - let val = { - let mut cstr = [0i8; 128]; - let b = env.as_bytes(); - if b.len() + 1 > cstr.len() { - return None; - } - let mut i = 0; - while i < b.len() { - cstr[i] = b[i] as i8; - i += 1; - } - cstr[i] = 0; - let p = unsafe { libc::getenv(cstr.as_ptr()) }; - if p.is_null() { - return None; - } - let s = unsafe { core::ffi::CStr::from_ptr(p) }; - s.to_str().ok()? - }; - - #[cfg(windows)] - let val = { - // env → UTF‑16 name - let mut name_wide: [u16; 128] = [0; 128]; - for (name_len, c) in env.encode_utf16().enumerate() { - if name_len >= name_wide.len() - 1 { - return None; - } - name_wide[name_len] = c; - } - - // Get value length - let required = - unsafe { GetEnvironmentVariableW(name_wide.as_ptr(), core::ptr::null_mut(), 0) }; - if required == 0 { - return None; - } - - // Read value wide - let mut val_wide: [u16; 256] = [0; 256]; - if (required as usize) > val_wide.len() { - return None; - } - let n = unsafe { - GetEnvironmentVariableW( - name_wide.as_ptr(), - val_wide.as_mut_ptr(), - val_wide.len() as u32, - ) - }; - if n == 0 { - return None; - } - - // Wide → UTF‑8 via stack buffer - let utf8_len = unsafe { - WideCharToMultiByte( - 65001, 0, val_wide.as_ptr(), n as i32, - core::ptr::null_mut(), 0, - core::ptr::null(), core::ptr::null_mut(), - ) - }; - if utf8_len <= 0 || (utf8_len as usize) >= buf.len() { - return None; - } - let written = unsafe { - WideCharToMultiByte( - 65001, 0, val_wide.as_ptr(), n as i32, - buf.as_mut_ptr(), buf.len() as i32, - core::ptr::null(), core::ptr::null_mut(), - ) - }; - if written <= 0 { - return None; - } - core::str::from_utf8(&buf[..written as usize]).ok()? - }; - - parse(val) -} - -/// `mi_option_get` -pub fn get(option: Option) -> i64 { - let idx = option as usize; - match VALUES[idx].load(Ordering::Relaxed) { - i64::MIN => { - let (default, env) = DEFAULTS[idx]; - let v = read_env(env).unwrap_or(default); - VALUES[idx].store(v, Ordering::Relaxed); - v - } - v => v, - } -} - -/// `mi_option_set` -pub fn set(option: Option, value: i64) { - VALUES[option as usize].store(value, Ordering::Relaxed); -} - -/// `mi_option_is_enabled` -pub fn is_enabled(option: Option) -> bool { - get(option) != 0 -} - -/// `mi_option_enable` / `mi_option_disable` -pub fn enable(option: Option) { - set(option, 1) -} -pub fn disable(option: Option) { - set(option, 0) -} - -/// `_mi_clock_now`: monotonic milliseconds. Only used for relative purge -/// deadlines (>= 10ms granularity), so on Apple the cheap commpage-cached -/// approximate clock is used; `CLOCK_MONOTONIC` there is a gettimeofday + -/// boottime computation that shows up in thread-churn profiles. -#[cfg(all(target_vendor = "apple", not(miri)))] -pub fn clock_now() -> i64 { - unsafe extern "C" { - fn clock_gettime_nsec_np(clock_id: libc::clockid_t) -> u64; - } - // SAFETY: no preconditions. - (unsafe { clock_gettime_nsec_np(libc::CLOCK_MONOTONIC_RAW_APPROX) } / 1_000_000) as i64 -} - -/// `_mi_clock_now`: monotonic milliseconds. -#[cfg(all(not(all(target_vendor = "apple", not(miri))), not(windows)))] -#[allow(clippy::unnecessary_cast)] // tv_sec/tv_nsec are narrower off-macOS -pub fn clock_now() -> i64 { - let mut ts = libc::timespec { - tv_sec: 0, - tv_nsec: 0, - }; - // SAFETY: ts is a valid out-pointer. - unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; - ts.tv_sec as i64 * 1000 + ts.tv_nsec as i64 / 1_000_000 -} - -/// `_mi_clock_now`: monotonic milliseconds (Windows via QueryPerformanceCounter). -#[cfg(all(not(all(target_vendor = "apple", not(miri))), windows))] -pub fn clock_now() -> i64 { - // SAFETY: QPF and QPC are safe to call at any time. - unsafe { - let mut freq: i64 = 0; - let mut count: i64 = 0; - QueryPerformanceFrequency(&mut freq); - QueryPerformanceCounter(&mut count); - if freq == 0 { - return 0; - } - (count / freq * 1000) + ((count % freq) * 1000 / freq) - } -} - -#[cfg(windows)] -unsafe extern "system" { - fn QueryPerformanceCounter(lpPerformanceCount: *mut i64) -> i32; - fn QueryPerformanceFrequency(lpFrequency: *mut i64) -> i32; - fn GetEnvironmentVariableW(lpName: *const u16, lpBuffer: *mut u16, nSize: u32) -> u32; - fn WideCharToMultiByte( - CodePage: u32, - dwFlags: u32, - lpWideCharStr: *const u16, - cchWideChar: i32, - lpMultiByteStr: *mut u8, - cbMultiByte: i32, - lpDefaultChar: *const u8, - lpUsedDefaultChar: *mut i32, - ) -> i32; -} diff --git a/vendor/rimalloc/src/os.rs b/vendor/rimalloc/src/os.rs deleted file mode 100644 index a5b6cdc3..00000000 --- a/vendor/rimalloc/src/os.rs +++ /dev/null @@ -1,842 +0,0 @@ -//! OS memory primitives (port of `os.c` and `prim/unix/prim.c`). -//! -//! Segments need `SEGMENT_ALIGN`-aligned virtual memory; we over-allocate -//! and trim, the same fallback mimalloc uses. Memory can be reserved -//! without commit (`PROT_NONE`) and committed/purged lazily. - -use core::ptr; -use core::sync::atomic::{AtomicUsize, Ordering}; - -#[cfg(not(windows))] -#[inline] -pub fn page_size() -> usize { - static PAGE_SIZE: AtomicUsize = AtomicUsize::new(0); - match PAGE_SIZE.load(Ordering::Relaxed) { - 0 => { - // SAFETY: sysconf is async-signal-safe and has no memory preconditions. - let sz = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; - PAGE_SIZE.store(sz, Ordering::Relaxed); - sz - } - sz => sz, - } -} - -#[cfg(windows)] -#[inline] -pub fn page_size() -> usize { - static PAGE_SIZE: AtomicUsize = AtomicUsize::new(0); - match PAGE_SIZE.load(Ordering::Relaxed) { - 0 => { - // SAFETY: GetSystemInfo writes into a caller-provided struct; no preconditions. - let mut info = unsafe { core::mem::zeroed::() }; - unsafe { GetSystemInfo(&mut info) }; - let sz = info.dwPageSize as usize; - PAGE_SIZE.store(sz, Ordering::Relaxed); - sz - } - sz => sz, - } -} - -/// An owned range of OS-allocated memory (provenance for a segment). -/// Mirrors the `MI_MEM_OS` case of `mi_memid_t`. -#[derive(Clone, Copy, Debug)] -pub struct OsMem { - pub base: *mut u8, - pub size: usize, - pub align: usize, - pub is_committed: bool, - pub is_zero: bool, -} - -// SAFETY: OsMem is a plain descriptor; ownership transfer is what Send is for. -unsafe impl Send for OsMem {} - -// Under Miri there is no virtual-memory model: partial munmap, PROT_NONE -// reservations, and madvise have no equivalent. Back segments with -// `std::alloc` instead; commit/decommit/reset become no-ops. -#[cfg(miri)] -mod shim { - use super::OsMem; - use core::alloc::Layout; - - impl OsMem { - pub fn alloc_aligned( - size: usize, - alignment: usize, - commit: bool, - ) -> Option<(*mut u8, OsMem)> { - let layout = Layout::from_size_align(size, alignment).ok()?; - // SAFETY: non-zero size and valid layout. - let p = unsafe { std::alloc::alloc_zeroed(layout) }; - if p.is_null() { - return None; - } - p.expose_provenance(); // segment lookups reconstruct pointers by address - Some(( - p, - OsMem { - base: p, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )) - } - - pub fn free(self) { - // SAFETY: base/size/align are exactly what alloc_aligned used. - unsafe { - std::alloc::dealloc( - self.base, - Layout::from_size_align_unchecked(self.size, self.align), - ) - }; - } - } - - pub unsafe fn commit(_p: *mut u8, _size: usize) -> bool { - false - } - pub unsafe fn decommit(_p: *mut u8, _size: usize) {} - pub unsafe fn reset(_p: *mut u8, _size: usize) {} -} - -#[cfg(miri)] -pub use shim::{commit, decommit, reset}; - -/// Syscall counters: [mmap, munmap, madvise, mprotect]. -pub static SYSCALL_COUNTS: [AtomicUsize; 4] = [const { AtomicUsize::new(0) }; 4]; - -pub fn syscall_counts() -> [usize; 4] { - SYSCALL_COUNTS.each_ref().map(|c| c.load(Ordering::Relaxed)) -} - -// --------------------------------------------------------------------------- -// Unix memory allocation (mmap / munmap / mprotect) -// --------------------------------------------------------------------------- - -#[cfg(not(any(miri, windows)))] -fn mmap(hint: *mut u8, size: usize, commit: bool) -> *mut u8 { - SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); - let prot = if commit { - libc::PROT_READ | libc::PROT_WRITE - } else { - libc::PROT_NONE - }; - let flags = libc::MAP_PRIVATE | libc::MAP_ANON; - // SAFETY: anonymous mapping; a hint address never forces placement. - let p = unsafe { libc::mmap(hint.cast(), size, prot, flags, -1, 0) }; - if p == libc::MAP_FAILED { - ptr::null_mut() - } else { - p.cast() - } -} - -#[cfg(not(any(miri, windows)))] -impl OsMem { - /// Allocate `size` bytes aligned to `alignment` (a power of two). - /// Returns the aligned pointer and the owning region. - pub fn alloc_aligned(size: usize, alignment: usize, commit: bool) -> Option<(*mut u8, OsMem)> { - debug_assert!(crate::constants::is_power_of_two(alignment)); - let size = crate::constants::align_up(size, page_size()); - let p = mmap(ptr::null_mut(), size, commit); - if p.is_null() { - return None; - } - if commit { - crate::stats::STATS.committed.increase(size); - } - if p.addr() & (alignment - 1) == 0 { - p.expose_provenance(); // segment lookups reconstruct pointers by address - return Some(( - p, - OsMem { - base: p, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )); - } - // Misaligned: over-allocate and trim both ends. - // SAFETY: p..p+size is our mapping. - unsafe { libc::munmap(p.cast(), size) }; - let over = size + alignment; - let p = mmap(ptr::null_mut(), over, commit); - if p.is_null() { - return None; - } - let aligned = ptr::with_exposed_provenance_mut::( - (p.addr() + alignment - 1) & !(alignment - 1), - ); - let pre = aligned.addr() - p.addr(); - let post = over - pre - size; - // SAFETY: trimming unused head/tail of our own fresh mapping. - unsafe { - if pre > 0 { - libc::munmap(p.cast(), pre); - } - if post > 0 { - libc::munmap(aligned.add(size).cast(), post); - } - } - aligned.expose_provenance(); // segment lookups reconstruct pointers by address - Some(( - aligned, - OsMem { - base: aligned, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )) - } - - /// Unmap the whole region. - pub fn free(self) { - SYSCALL_COUNTS[1].fetch_add(1, Ordering::Relaxed); - // SAFETY: base..base+size is an owned mapping; no references outlive it. - unsafe { libc::munmap(self.base.cast(), self.size) }; - } -} - -/// Commit a range: make it readable/writable. Returns `is_zero`. -/// -/// # Safety -/// `p..p+size` must lie within a single [`OsMem`] region. -#[cfg(not(any(miri, windows)))] -pub unsafe fn commit(p: *mut u8, size: usize) -> bool { - let (p, size) = page_span(p, size, true); - SYSCALL_COUNTS[3].fetch_add(1, Ordering::Relaxed); - // SAFETY: caller guarantees the range is within an owned mapping. - unsafe { libc::mprotect(p.cast(), size, libc::PROT_READ | libc::PROT_WRITE) }; - false // conservatively not known-zero (PROT_NONE ranges were never written though) -} - -/// Decommit a range: drop its pages and make it inaccessible. -/// -/// # Safety -/// `p..p+size` must lie within a single [`OsMem`] region and have no live -/// references. -#[cfg(not(any(miri, windows)))] -pub unsafe fn decommit(p: *mut u8, size: usize) { - let (p, size) = page_span(p, size, false); - if size == 0 { - return; - } - SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); - // SAFETY: per caller contract; MAP_FIXED re-map keeps the reservation. - unsafe { - libc::mmap( - p.cast(), - size, - libc::PROT_NONE, - libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_FIXED, - -1, - 0, - ); - } -} - -// --------------------------------------------------------------------------- -// Windows memory allocation (VirtualAlloc / VirtualFree / VirtualProtect) -// --------------------------------------------------------------------------- - -#[cfg(windows)] -unsafe extern "system" { - fn VirtualAlloc( - lpAddress: *mut core::ffi::c_void, - dwSize: usize, - flAllocationType: u32, - flProtect: u32, - ) -> *mut core::ffi::c_void; - fn VirtualFree( - lpAddress: *mut core::ffi::c_void, - dwSize: usize, - dwFreeType: u32, - ) -> i32; - fn VirtualProtect( - lpAddress: *mut core::ffi::c_void, - dwSize: usize, - flNewProtect: u32, - lpflOldProtect: *mut u32, - ) -> i32; - fn GetSystemInfo(lpSystemInfo: *mut SYSTEM_INFO); -} - -#[cfg(windows)] -#[repr(C)] -#[allow(non_snake_case)] -struct SYSTEM_INFO { - _union: [u8; 4], // wProcessorArchitecture union - dwPageSize: u32, - lpMinimumApplicationAddress: *mut core::ffi::c_void, - lpMaximumApplicationAddress: *mut core::ffi::c_void, - dwActiveProcessorMask: usize, - dwNumberOfProcessors: u32, - dwProcessorType: u32, - dwAllocationGranularity: u32, - wProcessorLevel: u16, - wProcessorRevision: u16, -} - -#[cfg(windows)] -const MEM_COMMIT: u32 = 0x1000; -#[cfg(windows)] -const MEM_RESERVE: u32 = 0x2000; -#[cfg(windows)] -const MEM_RELEASE: u32 = 0x8000; -#[cfg(windows)] -const MEM_DECOMMIT: u32 = 0x4000; -#[cfg(windows)] -const MEM_RESET: u32 = 0x80000; -#[cfg(windows)] -const PAGE_NOACCESS: u32 = 0x01; -#[cfg(windows)] -const PAGE_READWRITE: u32 = 0x04; - -#[cfg(windows)] -fn mmap_win(size: usize, commit: bool) -> *mut u8 { - SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); - let flags = if commit { MEM_COMMIT | MEM_RESERVE } else { MEM_RESERVE }; - let prot = if commit { PAGE_READWRITE } else { PAGE_NOACCESS }; - // SAFETY: plain anonymous allocation. - let p = unsafe { VirtualAlloc(ptr::null_mut(), size, flags, prot) }; - p.cast() -} - -#[cfg(windows)] -impl OsMem { - pub fn alloc_aligned(size: usize, alignment: usize, commit: bool) -> Option<(*mut u8, OsMem)> { - debug_assert!(crate::constants::is_power_of_two(alignment)); - let size = crate::constants::align_up(size, page_size()); - let p = mmap_win(size, commit); - if p.is_null() { - return None; - } - if commit { - crate::stats::STATS.committed.increase(size); - } - if p.addr() & (alignment - 1) == 0 { - p.expose_provenance(); - return Some(( - p, - OsMem { - base: p, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )); - } - // Misaligned: over-allocate, release the over-allocation, then - // re-allocate exactly at the aligned address. This releases the - // prefix/suffix virtual address range entirely (no waste), unlike - // decommit which keeps them reserved. - unsafe { VirtualFree(p.cast(), 0, MEM_RELEASE) }; - let over = size + alignment; - let p = mmap_win(over, commit); - if p.is_null() { - return None; - } - let aligned = ptr::with_exposed_provenance_mut::( - (p.addr() + alignment - 1) & !(alignment - 1), - ); - - unsafe { VirtualFree(p.cast(), 0, MEM_RELEASE) }; - let flags = if commit { MEM_COMMIT | MEM_RESERVE } else { MEM_RESERVE }; - let prot = if commit { PAGE_READWRITE } else { PAGE_NOACCESS }; - if !unsafe { VirtualAlloc(aligned.cast(), size, flags, prot).is_null() } { - aligned.expose_provenance(); - return Some(( - aligned, - OsMem { - base: aligned, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )); - } - - // Race: another thread took the address. Fall back to the - // decommit approach (wastes virtual address space but works). - let p = mmap_win(over, commit); - if p.is_null() { - return None; - } - let aligned = ptr::with_exposed_provenance_mut::( - (p.addr() + alignment - 1) & !(alignment - 1), - ); - let pre = aligned.addr() - p.addr(); - let post = over - pre - size; - unsafe { - if pre > 0 { - VirtualFree(p.cast(), pre, MEM_DECOMMIT); - } - if post > 0 { - VirtualFree(aligned.add(size).cast(), post, MEM_DECOMMIT); - } - } - aligned.expose_provenance(); - Some(( - aligned, - OsMem { - base: p, - size, - align: alignment, - is_committed: commit, - is_zero: true, - }, - )) - } - - pub fn free(self) { - SYSCALL_COUNTS[1].fetch_add(1, Ordering::Relaxed); - // SAFETY: self.base is the original VirtualAlloc base. - unsafe { VirtualFree(self.base.cast(), 0, MEM_RELEASE) }; - } -} - -#[cfg(windows)] -pub unsafe fn commit(p: *mut u8, size: usize) -> bool { - let (p, size) = page_span(p, size, true); - if size == 0 { - return false; - } - SYSCALL_COUNTS[3].fetch_add(1, Ordering::Relaxed); - // SAFETY: caller guarantees the range is within an owned mapping. - let ret = unsafe { VirtualAlloc(p.cast(), size, MEM_COMMIT, PAGE_READWRITE) }; - !ret.is_null() -} - -#[cfg(windows)] -pub unsafe fn decommit(p: *mut u8, size: usize) { - let (p, size) = page_span(p, size, false); - if size == 0 { - return; - } - SYSCALL_COUNTS[0].fetch_add(1, Ordering::Relaxed); - // SAFETY: per caller contract. - unsafe { VirtualFree(p.cast(), size, MEM_DECOMMIT) }; -} - -// --------------------------------------------------------------------------- -// Cross-platform helpers -// --------------------------------------------------------------------------- - -/// `_mi_os_good_alloc_size`: round a large allocation up to a tier-aligned -/// size, so `usable_size` of a large/huge block equals `good_size` (C parity). -pub fn good_alloc_size(size: usize) -> usize { - let align = if size < 512 * 1024 { - page_size() - } else if size < 2 * 1024 * 1024 { - 64 * 1024 - } else if size < 8 * 1024 * 1024 { - 256 * 1024 - } else if size < 32 * 1024 * 1024 { - 1024 * 1024 - } else { - 4 * 1024 * 1024 - }; - if size >= usize::MAX - align { - size - } else { - crate::constants::align_up(size, align) - } -} - -/// Purge: release physical pages but keep the range accessible. -/// -/// # Safety -/// `p..p+size` must lie within a single committed [`OsMem`] region whose -/// contents may be discarded. -#[cfg(not(any(miri, windows)))] -pub unsafe fn reset(p: *mut u8, size: usize) { - let (p, size) = page_span(p, size, false); - if size == 0 { - return; - } - #[cfg(target_os = "macos")] - const ADVICE: libc::c_int = libc::MADV_FREE; - #[cfg(not(target_os = "macos"))] - const ADVICE: libc::c_int = libc::MADV_DONTNEED; - SYSCALL_COUNTS[2].fetch_add(1, Ordering::Relaxed); - // SAFETY: per caller contract. - unsafe { libc::madvise(p.cast(), size, ADVICE) }; -} - -#[cfg(windows)] -pub unsafe fn reset(p: *mut u8, size: usize) { - let (p, size) = page_span(p, size, false); - if size == 0 { - return; - } - SYSCALL_COUNTS[2].fetch_add(1, Ordering::Relaxed); - // MEM_RESET tells the OS the pages are no longer needed - // (physical pages are released, contents discarded). - // SAFETY: per caller contract. - unsafe { VirtualAlloc(p.cast(), size, MEM_RESET, PAGE_READWRITE) }; -} - -/// Round `(p, size)` to OS page boundaries: conservatively outward when -/// `expand`, inward otherwise. -#[cfg(not(miri))] -fn page_span(p: *mut u8, size: usize, expand: bool) -> (*mut u8, usize) { - let ps = page_size(); - let (start, end) = if expand { - (p.addr() & !(ps - 1), (p.addr() + size + ps - 1) & !(ps - 1)) - } else { - ( - (p.addr() + ps - 1) & !(ps - 1), - (p.addr() + size) & !(ps - 1), - ) - }; - (p.with_addr(start), end.saturating_sub(start)) -} - -// --------------------------------------------------------------------------- -// Deferred frees -// --------------------------------------------------------------------------- -// Freeing a segment while `&Segment`/`&Page` arguments are still on the call -// stack would deallocate memory that live (protected) references point into. -// Instead the region is pushed on a pending list — the node is written into -// the dead region itself — and unmapped later from a shallow call frame. - -use core::sync::atomic::AtomicPtr; - -/// Intrusive node placement for dead regions: at the *tail*, away from the -/// segment/heap headers which may still be covered by live (protected) -/// references up the call stack when the region is queued. -/// -/// # Safety -/// The region must be dead and its tail page committed. -pub(crate) unsafe fn tail_node(mem: &OsMem) -> *mut T { - debug_assert!(size_of::() <= 64 && mem.size >= 128); - // SAFETY: in bounds, 16-aligned (size is page aligned). - unsafe { mem.base.add(mem.size - 64).cast() } -} - -struct PendingFree { - next: *mut PendingFree, - mem: OsMem, -} - -static PENDING: AtomicPtr = AtomicPtr::new(ptr::null_mut()); - -/// Queue an [`OsMem`] region for deallocation at the next [`drain_deferred`]. -pub fn defer_free(mem: OsMem) { - // SAFETY: dead region; ensure the tail page is committed (lazily - // committed segments may have an uncommitted tail, and decommit-mode - // purges may have dropped it). Stat-balanced: the region's committed - // bytes were already counted out by the caller. - let node = unsafe { - let node = tail_node::(&mem); - commit(node.cast(), 64); - node - }; - let mut head = PENDING.load(Ordering::Relaxed); - loop { - // SAFETY: the region is dead; we own it until the actual unmap. - unsafe { node.write(PendingFree { next: head, mem }) }; - match PENDING.compare_exchange_weak(head, node, Ordering::Release, Ordering::Relaxed) { - Ok(_) => return, - Err(h) => head = h, - } - } -} - -/// Release all pending regions. Call only from frames that hold no -/// references into allocator-owned memory regions. -pub fn drain_deferred() { - cache_purge_expired(); - if PENDING.load(Ordering::Relaxed).is_null() { - return; - } - let mut node = PENDING.swap(ptr::null_mut(), Ordering::Acquire); - while !node.is_null() { - // SAFETY: nodes are exclusively ours after the swap. - let PendingFree { next, mem } = unsafe { node.read() }; - mem.free(); - node = next; - } -} - -// --------------------------------------------------------------------------- -// Segment cache -// --------------------------------------------------------------------------- -// Returning each 32MiB segment to the OS makes segment churn syscall-bound -// (mimalloc avoids this with arenas). Cache standard segments on a global -// free stack: physical pages are released with MADV_FREE on push, but the -// mapping stays alive so reuse needs no syscall at all. - -const CACHE_MAX: usize = 64; - -struct CachedRegion { - next: *mut CachedRegion, - mem: OsMem, - expire: i64, // purge physical pages if still cached past this time - purged: bool, -} - -struct Cache { - lock: core::sync::atomic::AtomicBool, - first: core::cell::Cell<*mut CachedRegion>, - count: AtomicUsize, -} -// SAFETY: `first` is only accessed under `lock`. -unsafe impl Sync for Cache {} - -static CACHE: Cache = Cache { - lock: core::sync::atomic::AtomicBool::new(false), - first: core::cell::Cell::new(ptr::null_mut()), - count: AtomicUsize::new(0), -}; - -/// Earliest expiry among cached regions (0 = nothing pending). Checked -/// lock-free on the allocation path; only when it passes do we take the -/// cache lock (the previous unconditional lock+walk serialized all threads -/// in `malloc_generic`). -static PURGE_EXPIRE: core::sync::atomic::AtomicI64 = core::sync::atomic::AtomicI64::new(0); - -fn cache_locked(f: impl FnOnce() -> R) -> R { - crate::sync::spin_locked(&CACHE.lock, f) -} - -/// Try to cache a standard segment region; false if the cache is full or the -/// region is non-standard. -pub fn cache_push(mem: OsMem, standard: bool) -> bool { - if !standard || CACHE.count.load(Ordering::Relaxed) >= CACHE_MAX { - return false; - } - // Ensure the whole mapping is committed (cached regions are handed out - // as fully committed): lazily committed segments may have uncommitted - // ranges and decommit-mode purges may have dropped some. - if !mem.is_committed || crate::options::purge_decommits() != 0 { - // SAFETY: we own the dead region. - unsafe { commit(mem.base, mem.size) }; - } - // SAFETY: dead region, fully committed above. - let node = unsafe { tail_node::(&mem) }; - let expire = crate::options::clock_now() + 10 * crate::options::purge_delay().max(1); - // Track the earliest pending expiry (monotonic clock, so a simple - // "set if sooner or unset" race is benign). - let cur = PURGE_EXPIRE.load(Ordering::Relaxed); - if cur == 0 || expire < cur { - PURGE_EXPIRE.store(expire, Ordering::Relaxed); - } - cache_locked(|| { - // SAFETY: dead region, first page committed, owned by the cache. - unsafe { - node.write(CachedRegion { - next: CACHE.first.get(), - mem, - expire, - purged: false, - }); - } - CACHE.first.set(node); - }); - CACHE.count.fetch_add(1, Ordering::Relaxed); - true -} - -/// Pop a cached standard segment region (already committed, NOT zeroed). -pub fn cache_pop() -> Option<(*mut u8, OsMem)> { - if CACHE.count.load(Ordering::Relaxed) == 0 { - return None; - } - let mem = cache_locked(|| { - // SAFETY: list nodes are owned by the cache while linked. - unsafe { - let node = CACHE.first.get().as_ref()?; - CACHE.first.set(node.next); - Some(node.mem) - } - })?; - CACHE.count.fetch_sub(1, Ordering::Relaxed); - let mut mem = mem; - mem.is_zero = false; - mem.is_committed = true; - Some((mem.base, mem)) -} - -/// Purge physical pages of cached regions that sat unused past their -/// expiration (keeps RSS bounded without per-push madvise cost). -fn cache_purge_expired() { - // Lock-free fast path: nothing pending or not yet due. - let expire = PURGE_EXPIRE.load(Ordering::Relaxed); - if expire == 0 || CACHE.count.load(Ordering::Relaxed) == 0 { - return; - } - let now = crate::options::clock_now(); - if now < expire { - return; - } - // Collect candidates under the lock, purge outside it (madvise can be - // slow and must not extend the critical section). At most 8 regions per - // pass; the rest keep their purge bit and go out on a later drain. - let mut to_purge: [(*mut u8, usize); 8] = [(ptr::null_mut(), 0); 8]; - let mut n = 0; - cache_locked(|| { - let tail_span = page_size(); // keep the tail node page intact - let mut next_expire: i64 = 0; - let mut p = CACHE.first.get(); - // SAFETY: nodes are owned by the cache while linked and we hold the lock. - while let Some(node) = unsafe { p.as_mut() } { - if !node.purged && node.expire <= now && node.mem.size > 2 * tail_span && n < 8 { - node.purged = true; - to_purge[n] = (node.mem.base, node.mem.size - tail_span); - n += 1; - } else if !node.purged && (next_expire == 0 || node.expire < next_expire) { - next_expire = node.expire; - } - p = node.next; - } - PURGE_EXPIRE.store(next_expire, Ordering::Relaxed); - }); - for &(p, len) in &to_purge[..n] { - if !p.is_null() { - // SAFETY: purged ranges belong to cached (dead) regions; a - // racing pop simply refaults the pages. - unsafe { reset(p, len) }; - } - } -} - -/// Flush the segment cache back to the OS (used by `mi_collect(force)`). -pub fn cache_collect() { - while let Some((_, mem)) = cache_pop() { - mem.free(); - } -} - -// --------------------------------------------------------------------------- -// Huge OS pages -// --------------------------------------------------------------------------- - -/// Allocate `pages` 1GiB huge OS pages (`_mi_os_alloc_huge_os_pages`). -/// Supported on Linux via `MAP_HUGETLB`; unsupported elsewhere (returns -/// `None`, like mimalloc's graceful fallback). -#[cfg(all(target_os = "linux", not(any(miri, windows))))] -pub fn alloc_huge_os_pages(pages: usize) -> Option<(*mut u8, OsMem)> { - let size = pages << 30; - const MAP_HUGE_1GB: libc::c_int = 30 << 26; // MAP_HUGE_SHIFT - // SAFETY: anonymous huge-page mapping. - let p = unsafe { - libc::mmap( - ptr::null_mut(), - size, - libc::PROT_READ | libc::PROT_WRITE, - libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_HUGETLB | MAP_HUGE_1GB, - -1, - 0, - ) - }; - if p == libc::MAP_FAILED { - return None; - } - let p: *mut u8 = p.cast(); - p.expose_provenance(); - Some(( - p, - OsMem { - base: p, - size, - align: 1 << 30, - is_committed: true, - is_zero: true, - }, - )) -} - -#[cfg(not(all(target_os = "linux", not(any(miri, windows)))))] -pub fn alloc_huge_os_pages(_pages: usize) -> Option<(*mut u8, OsMem)> { - None // no huge OS page support on this platform -} - -/// Make a range inaccessible (`_mi_os_protect`); no-op under Miri. -/// -/// # Safety -/// `p..p+size` must lie within an owned, committed mapping with no live -/// references. -pub unsafe fn protect(p: *mut u8, size: usize) { - #[cfg(not(any(miri, windows)))] - // SAFETY: per caller contract. - unsafe { - libc::mprotect(p.cast(), size, libc::PROT_NONE); - } - #[cfg(windows)] - // SAFETY: per caller contract. - unsafe { - let mut old = 0u32; - VirtualProtect(p.cast(), size, PAGE_NOACCESS, &mut old); - } - #[cfg(miri)] - let _ = (p, size); -} - -/// Restore read/write access (`_mi_os_unprotect`); no-op under Miri. -/// -/// # Safety -/// `p..p+size` must lie within an owned mapping. -pub unsafe fn unprotect(p: *mut u8, size: usize) { - #[cfg(not(any(miri, windows)))] - // SAFETY: per caller contract. - unsafe { - libc::mprotect(p.cast(), size, libc::PROT_READ | libc::PROT_WRITE); - } - #[cfg(windows)] - // SAFETY: per caller contract. - unsafe { - let mut old = 0u32; - VirtualProtect(p.cast(), size, PAGE_READWRITE, &mut old); - } - #[cfg(miri)] - let _ = (p, size); -} - -// --------------------------------------------------------------------------- -// fork() handling -// --------------------------------------------------------------------------- -// fork() snapshots memory mid-flight: if another thread holds one of the -// allocator's spinlocks at fork, the child would inherit a locked lock (and -// a possibly half-edited list) forever. `prepare` acquires every global -// lock so the snapshot is consistent; `parent`/`child` release them. Other -// threads do not exist in the child, so their heaps/segments simply remain -// abandoned-or-orphaned there (same policy as mimalloc). - -/// All global spinlocks, in a fixed acquisition order. -#[cfg(not(windows))] -fn fork_locks() -> [&'static AtomicBool; 2] { - [&CACHE.lock, crate::init::td_cache_lock()] -} - -#[cfg(not(windows))] -pub(crate) extern "C" fn fork_prepare() { - crate::segment::abandoned::lock_for_fork(); - for l in fork_locks() { - crate::sync::spin_acquire(l); - } - crate::arena::lock_for_fork(); -} - -#[cfg(not(windows))] -pub(crate) extern "C" fn fork_release() { - crate::arena::unlock_for_fork(); - for l in fork_locks() { - crate::sync::spin_release(l); - } - crate::segment::abandoned::unlock_for_fork(); -} - -#[cfg(not(windows))] -use core::sync::atomic::AtomicBool; diff --git a/vendor/rimalloc/src/page.rs b/vendor/rimalloc/src/page.rs deleted file mode 100644 index caa0c03d..00000000 --- a/vendor/rimalloc/src/page.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! The mimalloc page (`mi_page_t`): a 64KiB–512KiB region serving blocks of -//! one size class, with three free lists: -//! - `free`: blocks `malloc` pops from (owner thread only), -//! - `local_free`: blocks freed by the owner, migrated to `free` on exhaustion, -//! - `xthread_free`: lock-free stack of blocks freed by other threads. -//! -//! Invariants (see `types.h`): -//! `used - |thread_free|` = live blocks; -//! `used + |free| + |local_free| = capacity <= reserved`. -//! -//! All fields are `Cell`/atomics and accessed through `&Page`, never `&mut`: -//! page metadata lives inside segments and is reachable from other threads -//! (via `xthread_free`), so exclusive references would be unsound. - -use core::cell::Cell; -use core::ptr; -use core::sync::atomic::{AtomicUsize, Ordering}; - -use crate::constants::*; -use crate::heap::Heap; -use crate::segment::Segment; - -/// A free block: the first word is the (unencoded) next pointer. -#[repr(transparent)] -pub struct Block { - pub next: Cell<*mut Block>, -} - -impl Block { - /// View a user pointer as a block pointer (a plain cast; any - /// dereference remains the caller's obligation). - #[inline(always)] - pub fn at(p: *mut u8) -> *mut Block { - p.cast() - } - - /// Set the next pointer of a block that is *entering* a free list. - /// Must not go through `Cell::set` (= `replace`), which reads the old - /// value: the block's memory may be uninitialized or hold partial user - /// data, and reading it as a pointer is UB. - /// - /// # Safety - /// `this` must point to block-sized memory owned by the allocator. - #[inline(always)] - pub unsafe fn write_next(this: *mut Block, next: *mut Block) { - // SAFETY: per contract; plain write, no read of the old value. - unsafe { - this.write(Block { - next: Cell::new(next), - }) - } - } -} - -/// `mi_delayed_t`: the bottom two bits of `xthread_free`. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[repr(u8)] -pub enum Delayed { - /// Push on the owning heap's delayed list. - UseDelayedFree = 0, - /// Transient: another thread is accessing the owning heap. - Freeing = 1, - /// Push on the page-local thread free queue. - NoDelayedFree = 2, - /// Sticky: page is abandoned (no owning heap); reset on reclaim. - NeverDelayedFree = 3, -} - -impl Delayed { - #[inline(always)] - pub fn from_bits(bits: usize) -> Delayed { - match bits & 3 { - 0 => Delayed::UseDelayedFree, - 1 => Delayed::Freeing, - 2 => Delayed::NoDelayedFree, - _ => Delayed::NeverDelayedFree, - } - } -} - -/// `mi_thread_free_t`: a block pointer with [`Delayed`] flags in the low -/// bits and the list length in the top 16 (see `protocol::TF_COUNT_SHIFT`). -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct ThreadFree(pub usize); - -impl ThreadFree { - #[inline(always)] - pub fn block(self) -> *mut Block { - ptr::with_exposed_provenance_mut(self.0 & crate::protocol::TF_PTR_MASK) - } - #[inline(always)] - pub fn count(self) -> usize { - self.0 >> crate::protocol::TF_COUNT_SHIFT - } - #[inline(always)] - pub fn delayed(self) -> Delayed { - Delayed::from_bits(self.0) - } -} - -/// A mimalloc page. When unallocated, the same struct doubles as a slice -/// span header (`mi_slice_t`): `slice_count`/`slice_offset` describe the -/// span and `block_size` is 1 if used, 0 if free. -/// -/// The metadata is split into three cache lines (128 bytes on Apple -/// Silicon) so concurrent cross-thread frees neither false-share with the -/// owner's malloc fast path nor with *each other*: -/// - line 0 is init-time-constant and read-shared (span lookup fields and -/// the unalign constants every foreign free needs) — it stays valid in -/// every core's cache; -/// - line 1 holds only the contended atomics (`xthread_free`, `xheap`) -/// that foreign frees CAS, so that traffic invalidates nothing else; -/// - line 2 has the owner's per-allocation fields (`free`, `local_free`, -/// `used`, queue links). -#[repr(C, align(128))] -pub struct Page { - // ---- line 0: init-constant, read by everyone ---- - // "Owned" by the segment: - pub slice_count: Cell, // slices in this page (0 if not a page) - pub slice_offset: Cell, // byte distance to the first slice of the span - pub is_committed: Cell, - pub is_zero_init: Cell, - pub is_huge: Cell, - pub heap_tag: Cell, - pub block_size_shift: Cell, // nonzero iff block_size == 1<, - pub page_start: Cell<*mut u8>, - /// Lemire fastmod reciprocal of `block_size` (`u64::MAX / b + 1`), or 0 - /// when unusable; lets `page_ptr_unalign` avoid a hardware divide. - pub block_recip: Cell, - - _pad0: [u8; 88], - - // ---- line 1: the atomics foreign frees write ---- - pub xthread_free: AtomicUsize, - pub xheap: AtomicUsize, - - _pad1: [u8; 112], - - // ---- line 2: owner-thread hot fields ---- - pub free: Cell<*mut Block>, - pub local_free: Cell<*mut Block>, - pub used: Cell, - pub capacity: Cell, // blocks committed - pub reserved: Cell, // blocks reserved in memory - /// Bit 0: page is in the full queue; bit 1: contains aligned (interior) - /// blocks. One byte so the free fast path tests both with one load. - flags: Cell, - pub free_is_zero: Cell, - pub retire_expire: Cell, - - pub next: Cell<*mut Page>, - pub prev: Cell<*mut Page>, - - /// Random keys encoding the free lists (`secure` feature; `MI_ENCODE_FREELIST`). - pub keys: [Cell; 2], -} - -// SAFETY: pages are reachable from other threads, but those only touch the -// atomic fields (`xthread_free`, `xheap`) and the constant-while-shared -// fields (`page_start`, `block_size`, `block_size_shift`), mirroring -// mimalloc's discipline. -unsafe impl Sync for Page {} - -/// A slice span header is the same struct viewed differently. -pub type Slice = Page; - -/// Free-list pointer encoding (`mi_ptr_encode`/`mi_ptr_decode`): with the -/// `secure` feature, next pointers are stored as -/// `rotl(ptr ^ k1, k0 % BITS) + k0` with per-page (or per-heap) random -/// keys, detecting corrupted lists and making overwrites useless to an -/// attacker. `null` is substituted by the list owner's address so an -/// encoded null is not a key oracle. -#[inline(always)] -pub fn ptr_encode(null_sub: usize, p: *mut Block, keys: &[Cell; 2]) -> *mut Block { - if cfg!(feature = "secure") { - let x = if p.is_null() { null_sub } else { p.addr() }; - let (k0, k1) = (keys[0].get(), keys[1].get()); - ptr::with_exposed_provenance_mut( - (x ^ k1) - .rotate_left((k0 % usize::BITS as usize) as u32) - .wrapping_add(k0), - ) - } else { - p - } -} - -#[inline(always)] -pub fn ptr_decode(null_sub: usize, enc: *mut Block, keys: &[Cell; 2]) -> *mut Block { - if cfg!(feature = "secure") { - let (k0, k1) = (keys[0].get(), keys[1].get()); - let x = enc - .addr() - .wrapping_sub(k0) - .rotate_right((k0 % usize::BITS as usize) as u32) - ^ k1; - if x == null_sub { - ptr::null_mut() - } else { - ptr::with_exposed_provenance_mut(x) - } - } else { - enc - } -} - -impl Page { - #[allow(clippy::declare_interior_mutable_const)] // template value, never read in place - pub const EMPTY: Page = Page { - slice_count: Cell::new(0), - slice_offset: Cell::new(0), - is_committed: Cell::new(false), - is_zero_init: Cell::new(false), - is_huge: Cell::new(false), - capacity: Cell::new(0), - reserved: Cell::new(0), - flags: Cell::new(0), - free_is_zero: Cell::new(false), - retire_expire: Cell::new(0), - free: Cell::new(ptr::null_mut()), - local_free: Cell::new(ptr::null_mut()), - used: Cell::new(0), - block_size_shift: Cell::new(0), - heap_tag: Cell::new(0), - block_size: Cell::new(0), - page_start: Cell::new(ptr::null_mut()), - block_recip: Cell::new(0), - xthread_free: AtomicUsize::new(0), - xheap: AtomicUsize::new(0), - _pad0: [0; 88], - _pad1: [0; 112], - next: Cell::new(ptr::null_mut()), - prev: Cell::new(ptr::null_mut()), - keys: [Cell::new(0), Cell::new(0)], - }; - - /// Set `block_size` together with its fastmod reciprocal (valid for - /// `0 < b < 2^32`; otherwise `page_ptr_unalign` falls back to `%`). - pub fn set_block_size(&self, b: usize) { - debug_assert!(b > 0); - self.block_size.set(b); - self.block_recip.set(if (b as u64) < (1 << 32) { - u64::MAX / b as u64 + 1 - } else { - 0 - }); - } - - /// `_mi_page_segment`: pages live inside their segment, so mask the address. - /// The segment mapping's provenance is exposed at allocation, so the - /// masked address reconstructs a valid pointer. - #[inline(always)] - pub fn segment(&self) -> &Segment { - // SAFETY: a live page is embedded in the slices array of its - // SEGMENT_ALIGN-aligned segment whose provenance was exposed. - unsafe { &*ptr::with_exposed_provenance(ptr::from_ref(self).addr() & !SEGMENT_MASK) } - } - - #[inline(always)] - pub fn start(&self) -> *mut u8 { - debug_assert!(!self.page_start.get().is_null()); - self.page_start.get() - } - - /// `mi_page_thread_free` - #[inline(always)] - pub fn thread_free(&self) -> ThreadFree { - ThreadFree(self.xthread_free.load(Ordering::Relaxed)) - } - - /// `mi_page_heap` - #[inline(always)] - pub fn heap(&self) -> *mut Heap { - ptr::with_exposed_provenance_mut(self.xheap.load(Ordering::Relaxed)) - } - - /// `mi_page_set_heap` - #[inline] - pub fn set_heap(&self, heap: *mut Heap) { - debug_assert!(self.thread_free().delayed() != Delayed::Freeing); - self.xheap - .store(heap.expose_provenance(), Ordering::Release); - if !heap.is_null() { - // SAFETY: heap outlives its pages; tag is plain data. - self.heap_tag.set(unsafe { (*heap).tag.get() }); - } - } - - const FLAG_IN_FULL: u8 = 0x01; - const FLAG_HAS_ALIGNED: u8 = 0x02; - - #[inline(always)] - pub fn in_full(&self) -> bool { - self.flags.get() & Self::FLAG_IN_FULL != 0 - } - - #[inline(always)] - pub fn set_in_full(&self, v: bool) { - let f = self.flags.get() & !Self::FLAG_IN_FULL; - self.flags.set(f | if v { Self::FLAG_IN_FULL } else { 0 }); - } - - #[inline(always)] - pub fn has_aligned(&self) -> bool { - self.flags.get() & Self::FLAG_HAS_ALIGNED != 0 - } - - #[inline(always)] - pub fn set_has_aligned(&self, v: bool) { - let f = self.flags.get() & !Self::FLAG_HAS_ALIGNED; - self.flags - .set(f | if v { Self::FLAG_HAS_ALIGNED } else { 0 }); - } - - /// True if the local free path must take the generic route (full-queue - /// bookkeeping or interior aligned pointers): a single byte test. - #[inline(always)] - pub fn full_or_aligned(&self) -> bool { - self.flags.get() != 0 - } - - /// `mi_page_all_free`: needs an up-to-date `used` count. - #[inline(always)] - pub fn all_free(&self) -> bool { - self.used.get() == 0 - } - - /// `mi_page_has_any_available` - #[inline(always)] - pub fn has_any_available(&self) -> bool { - debug_assert!(self.reserved.get() > 0); - self.used.get() < self.reserved.get() || !self.thread_free().block().is_null() - } - - /// `mi_page_immediate_available` - #[inline(always)] - pub fn immediate_available(&self) -> bool { - !self.free.get().is_null() - } - - /// `mi_page_is_mostly_used`: more than 7/8th in use. - #[inline] - pub fn is_mostly_used(&self) -> bool { - let frac = self.reserved.get() / 8; - self.reserved.get() - self.used.get() <= frac - } - - #[inline(always)] - pub fn block_size(&self) -> usize { - debug_assert!(self.block_size.get() > 0); - self.block_size.get() - } - - /// `mi_page_usable_block_size`: without fixed padding. - #[inline(always)] - pub fn usable_block_size(&self) -> usize { - self.block_size() - PADDING_SIZE - } - - // ---- slice-span view (`mi_slice_t`) ---- - - /// `mi_slice_first`: the head of the span this slice belongs to. - #[inline] - pub fn slice_first(&self) -> &Slice { - // SAFETY: slice_offset is the byte distance back to the span head - // within the same (exposed) segment mapping. - let first: &Slice = unsafe { - &*ptr::with_exposed_provenance( - ptr::from_ref(self).addr() - self.slice_offset.get() as usize, - ) - }; - debug_assert!(first.slice_offset.get() == 0); - first - } - - /// Whether this span is in use as a page (`block_size == 1` marker for - /// used spans that are not pages is set by the segment code). - #[inline(always)] - pub fn slice_is_used(&self) -> bool { - self.block_size.get() > 0 - } - - /// `mi_block_next`: read a block's next pointer (decoding under `secure`). - /// - /// # Safety - /// `b` must be a live free-list node of this page. - #[inline(always)] - pub unsafe fn block_next(&self, b: *mut Block) -> *mut Block { - // SAFETY: per contract. - let raw = unsafe { (*b).next.get() }; - ptr_decode(ptr::from_ref(self).addr(), raw, &self.keys) - } - - /// `mi_block_set_next`: link a block into one of this page's free lists - /// (write-only: the block's memory may be uninitialized). - /// - /// # Safety - /// `b` must point to block-sized memory of this page owned by the caller. - #[inline(always)] - pub unsafe fn block_set_next(&self, b: *mut Block, next: *mut Block) { - let enc = ptr_encode(ptr::from_ref(self).addr(), next, &self.keys); - // SAFETY: per contract. - unsafe { Block::write_next(b, enc) }; - } -} - -const _: () = assert!(size_of::() == 384); -const _: () = assert!(core::mem::offset_of!(Page, xthread_free) == 128); -const _: () = assert!(core::mem::offset_of!(Page, free) == 256); diff --git a/vendor/rimalloc/src/page_ops.rs b/vendor/rimalloc/src/page_ops.rs deleted file mode 100644 index e9537f23..00000000 --- a/vendor/rimalloc/src/page_ops.rs +++ /dev/null @@ -1,610 +0,0 @@ -//! The core of the allocator (port of `page.c`): page init/extend, free-list -//! collection, retire, and the generic allocation path. - -use core::ptr; -use core::sync::atomic::Ordering; - -use crate::constants::*; -use crate::heap::{Heap, PageQueue}; -use crate::page::{Block, Delayed, Page}; - -const MAX_RETIRE_CYCLES: u8 = 16; -const MAX_EXTEND_SIZE: usize = 4 * 1024; -const MIN_EXTEND: usize = 4; -const MAX_CANDIDATE_SEARCH: usize = 4; - -/// Block `i` of a page (`mi_page_block_at`). -#[inline(always)] -fn page_block_at(page_start: *mut u8, block_size: usize, i: usize) -> *mut Block { - // SAFETY: caller keeps i <= reserved; blocks lie inside the page area. - unsafe { page_start.add(i * block_size).cast() } -} - -// --------------------------------------------------------------------------- -// Delayed-free flag transitions -// --------------------------------------------------------------------------- - -/// `_mi_page_try_use_delayed_free` -pub fn page_try_use_delayed_free(page: &Page, delay: Delayed, override_never: bool) -> bool { - crate::protocol::try_use_delayed( - &page.xthread_free, - delay as usize, - override_never, - std::thread::yield_now, - ) -} - -/// `_mi_page_use_delayed_free` -pub fn page_use_delayed_free(page: &Page, delay: Delayed, override_never: bool) { - while !page_try_use_delayed_free(page, delay, override_never) { - std::thread::yield_now(); - } -} - -// --------------------------------------------------------------------------- -// Collect free lists -// --------------------------------------------------------------------------- - -/// `_mi_page_thread_free_collect`: atomically take the cross-thread free -/// list. The pushed count rides in the taken word, so the common case -/// (the malloc `free` list is exhausted — that is why we are collecting) -/// adopts the chain as-is without pointer-chasing remote cache lines; -/// only a non-empty `free` list requires walking to the tail to splice -/// into `local_free`. -fn page_thread_free_collect(page: &Page) { - let taken = crate::page::ThreadFree(crate::protocol::collect_take(&page.xthread_free)); - let head = taken.block(); - if head.is_null() { - return; - } - let count = taken.count(); - if count == 0 || count > page.capacity.get() as usize || count > page.used.get() as usize { - crate::error("corrupted thread-free list"); - return; - } - if page.free.get().is_null() { - page.free.set(head); - page.free_is_zero.set(false); - } else { - let mut tail = head; - for _ in 1..count { - // SAFETY: list nodes are free blocks in this page (ownership of - // the whole list was transferred by collect_take's AcqRel swap). - tail = unsafe { page.block_next(tail) }; - if tail.is_null() { - crate::error("corrupted thread-free list"); - return; - } - } - // SAFETY: tail is a live node of this page. - unsafe { page.block_set_next(tail, page.local_free.get()) }; - page.local_free.set(head); - } - page.used.set(page.used.get() - count as u16); -} - -/// Assert the Lean-proven counting invariant (`FreeList.lean::Inv`) on a -/// live page: `used + |free| + |local_free| = capacity <= reserved`. -/// Debug builds only — list walks are linear. -#[cfg(debug_assertions)] -pub fn page_assert_invariant(page: &Page) { - if page.capacity.get() == 0 { - return; // not initialized yet (fresh or cleared page) - } - let count = |mut p: *mut Block| { - let mut n = 0usize; - while !p.is_null() { - n += 1; - debug_assert!( - n <= page.capacity.get() as usize, - "free list longer than capacity" - ); - // SAFETY: list nodes are live blocks of this page. - p = unsafe { page.block_next(p) }; - } - n - }; - let free = count(page.free.get()); - let local = count(page.local_free.get()); - debug_assert_eq!( - page.used.get() as usize + free + local, - page.capacity.get() as usize, - "page counting invariant violated (used={} |free|={free} |local|={local} cap={})", - page.used.get(), - page.capacity.get(), - ); - debug_assert!(page.capacity.get() <= page.reserved.get()); -} - -/// `_mi_page_free_collect` -#[inline] -pub fn page_free_collect(page: &Page, force: bool) { - if force || !page.thread_free().block().is_null() { - page_thread_free_collect(page); - } - if !page.local_free.get().is_null() { - if page.free.get().is_null() { - page.free.set(page.local_free.get()); - page.local_free.set(ptr::null_mut()); - page.free_is_zero.set(false); - } else if force { - // append (linear); only on shutdown - // SAFETY: list nodes are free blocks in this page. - unsafe { - let mut tail = page.local_free.get(); - while !page.block_next(tail).is_null() { - tail = page.block_next(tail); - } - page.block_set_next(tail, page.free.get()); - } - page.free.set(page.local_free.get()); - page.local_free.set(ptr::null_mut()); - page.free_is_zero.set(false); - } - } - #[cfg(debug_assertions)] - page_assert_invariant(page); -} - -// --------------------------------------------------------------------------- -// Fresh pages and reclaim -// --------------------------------------------------------------------------- - -/// `_mi_page_reclaim` (from segment reclaim). -pub fn page_reclaim(heap: &Heap, page: &Page) { - debug_assert!(page.heap() == ptr::from_ref(heap).cast_mut()); - let pq = heap.page_queue(page.block_size()); - heap.page_queue_push(pq, page); -} - -/// `mi_page_fresh_alloc` -fn page_fresh_alloc<'a>( - heap: &'a Heap, - pq: Option<&PageQueue>, - block_size: usize, - page_alignment: usize, -) -> Option<&'a Page> { - let page = - crate::segment::segment_page_alloc(heap, block_size, page_alignment, heap.segments_tld())?; - let full_block_size = if pq.is_none() || page.is_huge.get() { - page.block_size() - } else { - block_size - }; - page_init(heap, page, full_block_size); - crate::stats::STATS.pages.increase(1); - if let Some(pq) = pq { - heap.page_queue_push(pq, page); - } - Some(page) -} - -/// `mi_page_fresh` -fn page_fresh<'a>(heap: &'a Heap, pq: &PageQueue) -> Option<&'a Page> { - page_fresh_alloc(heap, Some(pq), pq.block_size, 0) -} - -// --------------------------------------------------------------------------- -// Heap delayed free -// --------------------------------------------------------------------------- - -/// `_mi_heap_delayed_free_partial`: free blocks other threads pushed on our -/// heap's delayed list. Returns false if some were contended. -pub fn heap_delayed_free_partial(heap: &Heap) -> bool { - // Links of delayed blocks are encoded with their *page's* keys (set by - // `mt_push` via `block_set_next`); the page is recovered from the - // block address. - let link_page = |addr: usize| -> &Page { - // SAFETY: delayed blocks live in pages of segments owned by us. - unsafe { crate::segment::ptr_page(ptr::with_exposed_provenance(addr)) } - }; - crate::protocol::delayed_partial( - &heap.thread_delayed_free, - |addr| { - // SAFETY: we own the taken list. - unsafe { link_page(addr).block_next(ptr::with_exposed_provenance_mut(addr)) } - .expose_provenance() - }, - |addr, next| { - // SAFETY: re-queueing a block we own; write-only. - unsafe { - link_page(addr).block_set_next( - ptr::with_exposed_provenance_mut(addr), - ptr::with_exposed_provenance_mut(next), - ) - } - }, - |addr| crate::free::free_delayed_block(ptr::with_exposed_provenance_mut(addr)), - ) -} - -/// `_mi_heap_delayed_free_all` -pub fn heap_delayed_free_all(heap: &Heap) { - while !heap_delayed_free_partial(heap) { - std::thread::yield_now(); - } -} - -// --------------------------------------------------------------------------- -// Unfull, abandon, free, retire -// --------------------------------------------------------------------------- - -/// `_mi_page_unfull` -pub fn page_unfull(page: &Page) { - debug_assert!(page.in_full()); - if !page.in_full() { - return; - } - // SAFETY: called on the owning thread; the heap is live. - let heap = unsafe { &*page.heap() }; - let pqfull = &heap.pages[crate::bins::Bin::FULL.index()]; - heap.page_set_in_full(page, false); // to compute the right queue - let pq = heap.page_queue_of(page); - heap.page_set_in_full(page, true); - heap.page_queue_enqueue_from(pq, pqfull, page); -} - -/// `mi_page_to_full` -fn page_to_full(page: &Page, pq: &PageQueue) { - debug_assert!(!page.immediate_available() && !page.in_full()); - if page.in_full() { - return; - } - // SAFETY: owning thread; heap is live. - let heap = unsafe { &*page.heap() }; - heap.page_queue_enqueue_from(&heap.pages[crate::bins::Bin::FULL.index()], pq, page); - page_free_collect(page, false); // collect frees racing with the move -} - -/// `_mi_page_abandon` -pub fn page_abandon(page: &Page, pq: &PageQueue) { - // SAFETY: owning thread; heap is live. - let heap = unsafe { &*page.heap() }; - let segments_tld = heap.segments_tld(); - heap.page_queue_remove(pq, page); - debug_assert!(page.thread_free().delayed() == Delayed::NeverDelayedFree); - page.set_heap(ptr::null_mut()); - page.segment().page_abandon(page, segments_tld); -} - -/// `_mi_page_force_abandon` (used by `segments_try_abandon`, not yet ported) -pub fn page_force_abandon(page: &Page) { - // SAFETY: owning thread; heap is live. - let heap = unsafe { &*page.heap() }; - page_use_delayed_free(page, Delayed::NeverDelayedFree, false); - heap_delayed_free_all(heap); - if page.capacity.get() == 0 { - return; // freed already - } - let pq = heap.page_queue_of(page); - if page.all_free() { - page_free(page, pq, false); - } else { - page_abandon(page, pq); - } -} - -/// `_mi_page_free`: free a page with no used blocks. -pub fn page_free(page: &Page, pq: &PageQueue, force: bool) { - debug_assert!(page.all_free()); - debug_assert!(page.thread_free().delayed() != Delayed::Freeing); - page.set_has_aligned(false); - // SAFETY: owning thread; heap is live. - let heap = unsafe { &*page.heap() }; - let segments_tld = heap.segments_tld(); - heap.page_queue_remove(pq, page); - page.set_heap(ptr::null_mut()); - page.segment().page_free(page, force, segments_tld); -} - -/// `_mi_page_retire`: a page just became all-free; retire it lazily if it is -/// the only page in its queue (new allocations are likely coming). -pub fn page_retire(page: &Page) { - debug_assert!(page.all_free()); - page.set_has_aligned(false); - // SAFETY: owning thread; heap is live. - let heap = unsafe { &*page.heap() }; - let pq = heap.page_queue_of(page); - let bsize = page.block_size(); - if !pq.is_special() { - let page_ptr = ptr::from_ref(page).cast_mut(); - if pq.last.get() == page_ptr && pq.first.get() == page_ptr { - page.retire_expire.set(if bsize <= SMALL_OBJ_SIZE_MAX { - MAX_RETIRE_CYCLES - } else { - MAX_RETIRE_CYCLES / 4 - }); - let index = - (ptr::from_ref(pq).addr() - heap.pages.as_ptr().addr()) / size_of::(); - heap.page_retired_min - .set(heap.page_retired_min.get().min(index)); - heap.page_retired_max - .set(heap.page_retired_max.get().max(index)); - return; // don't free after all - } - } - page_free(page, pq, false); -} - -/// `_mi_heap_collect_retired` -pub fn heap_collect_retired(heap: &Heap, force: bool) { - let mut min = BIN_FULL; - let mut max = 0; - for bin in heap.page_retired_min.get()..=heap.page_retired_max.get().min(BIN_FULL) { - let pq = &heap.pages[bin]; - // SAFETY: queue nodes are live pages of this heap. - if let Some(page) = unsafe { pq.first.get().as_ref() } - && page.retire_expire.get() != 0 - { - if page.all_free() { - page.retire_expire.set(page.retire_expire.get() - 1); - if force || page.retire_expire.get() == 0 { - page_free(page, pq, force); - } else { - min = min.min(bin); - max = max.max(bin); - } - } else { - page.retire_expire.set(0); - } - } - } - heap.page_retired_min.set(min); - heap.page_retired_max.set(max); -} - -// --------------------------------------------------------------------------- -// Page init / extend -// --------------------------------------------------------------------------- - -/// `mi_page_free_list_extend`: build a sequential free list for blocks -/// `[capacity, capacity+extend)`. -fn page_free_list_extend(page: &Page, bsize: usize, extend: usize) { - debug_assert!(page.free.get().is_null() && page.local_free.get().is_null()); - let page_area = page.start(); - let start = page_block_at(page_area, bsize, page.capacity.get() as usize); - let last = page_block_at(page_area, bsize, page.capacity.get() as usize + extend - 1); - let mut block = start; - while block < last { - // SAFETY: fresh blocks within the page area; write-only since their - // memory is uninitialized. - let next = unsafe { block.cast::().add(bsize).cast::() }; - // SAFETY: `block` is a fresh allocator-owned block. - unsafe { page.block_set_next(block, next) }; - block = next; - } - // SAFETY: last is a fresh block; prepend to the existing free list. - unsafe { page.block_set_next(last, page.free.get()) }; - page.free.set(start); -} - -/// `mi_page_is_expandable` -#[inline] -fn page_is_expandable(page: &Page) -> bool { - debug_assert!(page.capacity.get() <= page.reserved.get()); - page.capacity.get() < page.reserved.get() -} - -/// `mi_page_extend_free`: extend capacity by initializing more free blocks -/// (at most one OS-page worth at a time to limit commit). -pub fn page_extend_free(page: &Page) -> bool { - if !page.free.get().is_null() { - return true; - } - if page.capacity.get() >= page.reserved.get() { - return true; - } - let bsize = page.block_size(); - let mut extend = (page.reserved.get() - page.capacity.get()) as usize; - let max_extend = if bsize >= MAX_EXTEND_SIZE { - MIN_EXTEND - } else { - (MAX_EXTEND_SIZE / bsize).max(MIN_EXTEND) - }; - extend = extend.min(max_extend); - crate::stats::STATS - .pages_extended - .fetch_add(1, Ordering::Relaxed); - page_free_list_extend(page, bsize, extend); - page.capacity.set(page.capacity.get() + extend as u16); - true -} - -/// `mi_page_init` -fn page_init(heap: &Heap, page: &Page, block_size: usize) { - let segment = page.segment(); - page.set_heap(ptr::from_ref(heap).cast_mut()); - page.set_block_size(block_size); - let (start, page_size) = segment.page_start(page); - page.page_start.set(start); - debug_assert!(block_size <= page_size); - debug_assert!(page_size / block_size < (1 << 16)); - page.reserved.set((page_size / block_size) as u16); - debug_assert!(page.reserved.get() > 0); - page.free_is_zero.set(page.is_zero_init.get()); - page.keys[0].set(crate::init::random_next() | 1); - page.keys[1].set(crate::init::random_next() | 1); - page.block_size_shift.set(if block_size.is_power_of_two() { - block_size.trailing_zeros() as u8 - } else { - 0 - }); - debug_assert!(page.capacity.get() == 0 && page.free.get().is_null()); - debug_assert!(page.used.get() == 0 && page.next.get().is_null()); - page_extend_free(page); -} - -// --------------------------------------------------------------------------- -// Find pages with free blocks -// --------------------------------------------------------------------------- - -/// `mi_page_queue_find_free_ex`: next-fit search with candidate preference. -fn page_queue_find_free_ex<'a>( - heap: &'a Heap, - pq: &PageQueue, - first_try: bool, -) -> Option<&'a Page> { - let mut candidate_count = 0; - let mut page_candidate: Option<&Page> = None; - let mut p = pq.first.get(); - - // SAFETY: queue nodes are live pages of this heap. - while let Some(page) = unsafe { p.as_ref() } { - let next = page.next.get(); - candidate_count += 1; - page_free_collect(page, false); - let immediate_available = page.immediate_available(); - - if !immediate_available && !page_is_expandable(page) { - // full: move to the full queue so we don't revisit it - page_to_full(page, pq); - } else { - match page_candidate { - None => { - page_candidate = Some(page); - candidate_count = 0; - } - // prefer to reuse fuller pages (so emptier ones can free up) - Some(c) - if page.used.get() >= c.used.get() - && !page.is_mostly_used() - && !page_is_expandable(page) => - { - page_candidate = Some(page); - } - _ => {} - } - if immediate_available || candidate_count > MAX_CANDIDATE_SEARCH { - break; - } - } - p = next; - } - - let mut page = page_candidate.or( - // SAFETY: p is either null or a live page. - unsafe { p.as_ref() }, - ); - if let Some(pg) = page - && !pg.immediate_available() - { - debug_assert!(page_is_expandable(pg)); - if !page_extend_free(pg) { - page = None; - } - } - - match page { - None => { - heap_collect_retired(heap, false); // perhaps make a page available - let page = page_fresh(heap, pq); - if page.is_none() && first_try { - // out-of-memory _or_ reclaimed an abandoned page with blocks - page_queue_find_free_ex(heap, pq, false) - } else { - page - } - } - Some(page) => { - heap.page_queue_move_to_front(pq, page); - page.retire_expire.set(0); - Some(page) - } - } -} - -/// `mi_find_free_page` -fn find_free_page(heap: &Heap, size: usize) -> Option<&Page> { - let pq = heap.page_queue(size); - // SAFETY: queue nodes are live pages of this heap. - if let Some(page) = unsafe { pq.first.get().as_ref() } { - page_free_collect(page, false); - if page.immediate_available() { - page.retire_expire.set(0); - return Some(page); - } - } - page_queue_find_free_ex(heap, pq, true) -} - -// --------------------------------------------------------------------------- -// General allocation -// --------------------------------------------------------------------------- - -/// `mi_large_huge_page_alloc` -fn large_huge_page_alloc(heap: &Heap, size: usize, page_alignment: usize) -> Option<&Page> { - let block_size = crate::os::good_alloc_size(size); - let is_huge = block_size > LARGE_OBJ_SIZE_MAX || page_alignment > 0; - let pq = heap.page_queue(if is_huge { - LARGE_OBJ_SIZE_MAX + 1 - } else { - block_size - }); - debug_assert!(!is_huge || pq.is_huge()); - page_fresh_alloc(heap, Some(pq), block_size, page_alignment) -} - -/// `mi_find_page` -fn find_page(heap: &Heap, size: usize, huge_alignment: usize) -> Option<&Page> { - let req_size = size - PADDING_SIZE; - if req_size > MEDIUM_OBJ_SIZE_MAX - PADDING_SIZE || huge_alignment > 0 { - if req_size > MAX_ALLOC_SIZE { - crate::error("allocation request is too large"); - None - } else { - large_huge_page_alloc(heap, size, huge_alignment) - } - } else { - find_free_page(heap, size) - } -} - -/// `_mi_malloc_generic`: the slow path behind the malloc fast path. -pub fn malloc_generic( - heap: &Heap, - size: usize, - zero: bool, - huge_alignment: usize, -) -> Option> { - crate::os::drain_deferred(); - - // initialize if necessary - let heap = if !heap.is_initialized() { - crate::init::heap_default() - } else { - heap - }; - - // administrative tasks every N generic calls - heap.generic_count.set(heap.generic_count.get() + 1); - if heap.generic_count.get() >= 100 { - heap.generic_collect_count - .set(heap.generic_collect_count.get() + heap.generic_count.get()); - heap.generic_count.set(0); - crate::hooks::deferred_free(heap, false); - heap_delayed_free_partial(heap); - if heap.generic_collect_count.get() >= 10000 { - heap.generic_collect_count.set(0); - crate::heap_ops::heap_collect(heap, false); - } - } - - let page = match find_page(heap, size, huge_alignment) { - Some(p) => Some(p), - None => { - // out of memory: collect hard and retry once - crate::heap_ops::heap_collect(heap, true); - find_page(heap, size, huge_alignment) - } - }?; - - debug_assert!(page.immediate_available()); - debug_assert!(page.block_size() >= size); - - crate::stats::stat_malloc(page.usable_block_size(), page.is_huge.get()); - let p = crate::alloc::page_malloc_zero(heap, page, size, zero); - if page.reserved.get() == page.used.get() { - page_to_full(page, heap.page_queue_of(page)); - } - p -} diff --git a/vendor/rimalloc/src/protocol.rs b/vendor/rimalloc/src/protocol.rs deleted file mode 100644 index 230ec414..00000000 --- a/vendor/rimalloc/src/protocol.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! The cross-thread delayed-free protocol — the *single* implementation, -//! generic over the atomic word type. Production (`free.rs`, -//! `page_ops.rs`) instantiates it with `core` atomics on the live -//! `Page`/`Heap` fields; `tests/loom_protocol.rs` instantiates the *same -//! functions* with loom atomics, so every interleaving of exactly this -//! code is model-checked. Block storage (next links) is abstracted as -//! read/write closures because blocks live in raw page memory in -//! production and in instrumented cells under loom; all CAS structure, -//! memory orderings, and flag transitions live here and only here. - -use core::sync::atomic::Ordering; - -pub const USE_DELAYED: usize = 0; -pub const FREEING: usize = 1; -pub const NO_DELAYED: usize = 2; -pub const NEVER_DELAYED: usize = 3; -pub const FLAG_MASK: usize = 3; - -/// The thread-free word packs `count << 48 | block_ptr | delayed_flags`: -/// pushers increment the count so the owner's collect learns the list -/// length without walking it (pointer-chasing remote cache lines). User -/// addresses fit in 48 bits on all supported targets, and the count cannot -/// exceed a page's block capacity (< 2^16). -pub const TF_COUNT_UNIT: usize = 1 << TF_COUNT_SHIFT; -pub const TF_COUNT_SHIFT: u32 = 48; -pub const TF_PTR_MASK: usize = TF_COUNT_UNIT - 1 - FLAG_MASK; - -/// Minimal atomic-word interface implemented by `core` and loom atomics. -pub trait AtomicWord { - fn load(&self, order: Ordering) -> usize; - fn store(&self, val: usize, order: Ordering); - fn swap(&self, val: usize, order: Ordering) -> usize; - fn compare_exchange_weak( - &self, - current: usize, - new: usize, - success: Ordering, - failure: Ordering, - ) -> Result; -} - -impl AtomicWord for core::sync::atomic::AtomicUsize { - fn load(&self, order: Ordering) -> usize { - self.load(order) - } - fn store(&self, val: usize, order: Ordering) { - self.store(val, order) - } - fn swap(&self, val: usize, order: Ordering) -> usize { - self.swap(val, order) - } - fn compare_exchange_weak( - &self, - current: usize, - new: usize, - success: Ordering, - failure: Ordering, - ) -> Result { - self.compare_exchange_weak(current, new, success, failure) - } -} - -/// `mi_free_block_delayed_mt`: push a foreign-owned block (`block` is its -/// address-with-provenance-exposed) onto the page's thread-free list, or — -/// on the first concurrent free into a full page — onto the owning heap's -/// delayed list, guarded by the FREEING handshake. `write_next` stores a -/// next-link into the (exclusively owned) dying block without reading its -/// old contents. -pub fn mt_push<'d, A: AtomicWord, D: AtomicWord + 'd>( - xthread: &A, - delayed: impl FnOnce() -> Option<&'d D>, - block: usize, - mut write_next: impl FnMut(usize), -) { - debug_assert!(block & !TF_PTR_MASK == 0 && block != 0); - let mut tfree = xthread.load(Ordering::Relaxed); - let use_delayed = loop { - let use_delayed = tfree & FLAG_MASK == USE_DELAYED; - let tfreex = if use_delayed { - (tfree & !FLAG_MASK) | FREEING - } else { - write_next(tfree & TF_PTR_MASK); - block | (tfree & !TF_PTR_MASK).wrapping_add(TF_COUNT_UNIT) - }; - match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Relaxed) { - Ok(_) => break use_delayed, - Err(v) => tfree = v, - } - }; - if use_delayed { - // First concurrent free into a full page: push on the heap's - // delayed list (the heap stays valid while FREEING is held). - if let Some(dlist) = delayed() { - let mut dfree = dlist.load(Ordering::Relaxed); - loop { - write_next(dfree); - match dlist.compare_exchange_weak( - dfree, - block, - Ordering::Release, - Ordering::Relaxed, - ) { - Ok(_) => break, - Err(v) => dfree = v, - } - } - } - // reset FREEING to NO_DELAYED - let mut tfree = xthread.load(Ordering::Relaxed); - loop { - debug_assert!(tfree & FLAG_MASK == FREEING); - let tfreex = (tfree & !FLAG_MASK) | NO_DELAYED; - match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Relaxed) - { - Ok(_) => break, - Err(v) => tfree = v, - } - } - } -} - -/// `_mi_page_thread_free_collect`'s atomic half: take the whole -/// thread-free list (head pointer *and* count), keeping the delayed flag -/// bits. Returns the taken word: decode with `TF_PTR_MASK` / -/// `TF_COUNT_SHIFT`. -pub fn collect_take(xthread: &A) -> usize { - let mut tfree = xthread.load(Ordering::Relaxed); - loop { - match xthread.compare_exchange_weak( - tfree, - tfree & FLAG_MASK, - Ordering::AcqRel, - Ordering::Relaxed, - ) { - Ok(_) => return tfree & !FLAG_MASK, - Err(v) => tfree = v, - } - } -} - -/// `_mi_page_try_use_delayed_free`: transition the delayed flag, spinning -/// (boundedly) while another thread holds FREEING. -pub fn try_use_delayed( - xthread: &A, - delay: usize, - override_never: bool, - mut yield_now: impl FnMut(), -) -> bool { - let mut yield_count = 0; - let mut tfree = xthread.load(Ordering::Acquire); - loop { - let old_delay = tfree & FLAG_MASK; - if old_delay == FREEING { - if yield_count >= 4 { - return false; - } - yield_count += 1; - yield_now(); - tfree = xthread.load(Ordering::Acquire); - continue; - } - if delay == old_delay { - return true; - } - if !override_never && old_delay == NEVER_DELAYED { - return true; - } - let tfreex = (tfree & !FLAG_MASK) | delay; - match xthread.compare_exchange_weak(tfree, tfreex, Ordering::Release, Ordering::Acquire) { - Ok(_) => return true, - Err(v) => tfree = v, - } - } -} - -/// `_mi_heap_delayed_free_partial`'s atomic shell: take the heap's delayed -/// list, process each block with `process` (which returns `false` when -/// contended), and re-queue failures. `read_next` reads the link of a -/// block we own; `write_next` re-links one for re-queueing. Returns true -/// if everything was freed. -pub fn delayed_partial( - delayed: &D, - mut read_next: impl FnMut(usize) -> usize, - mut write_next: impl FnMut(usize, usize), - mut process: impl FnMut(usize) -> bool, -) -> bool { - let mut block = delayed.load(Ordering::Relaxed); - while block != 0 { - match delayed.compare_exchange_weak(block, 0, Ordering::AcqRel, Ordering::Relaxed) { - Ok(_) => break, - Err(v) => block = v, - } - } - let mut all_freed = true; - while block != 0 { - let next = read_next(block); - if !process(block) { - // another thread still holds FREEING: requeue - all_freed = false; - let mut dfree = delayed.load(Ordering::Relaxed); - loop { - write_next(block, dfree); - match delayed.compare_exchange_weak( - dfree, - block, - Ordering::Release, - Ordering::Relaxed, - ) { - Ok(_) => break, - Err(v) => dfree = v, - } - } - } - block = next; - } - all_freed -} diff --git a/vendor/rimalloc/src/segment.rs b/vendor/rimalloc/src/segment.rs deleted file mode 100644 index 4991fcc2..00000000 --- a/vendor/rimalloc/src/segment.rs +++ /dev/null @@ -1,1852 +0,0 @@ -//! Segments (port of `segment.c`): 32MiB aligned chunks divided into 64KiB -//! slices. Pages are spans of slices; free spans are kept in per-thread span -//! queues binned by slice count. The segment header (including the `slices` -//! metadata array) occupies the first slices ("info slices"). - -use core::cell::Cell; -use core::ptr; -use core::sync::atomic::{AtomicUsize, Ordering}; - -use crate::constants::*; -use crate::heap::Heap; -use crate::options; -use crate::os::{self, OsMem}; -use crate::page::{Page, Slice}; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum SegmentKind { - Normal, // SEGMENT_SIZE with pages inside - Huge, // one huge page, variable size -} - -/// `mi_commit_mask_t`: one bit per commit chunk (= slice). -pub struct CommitMask { - mask: [Cell; COMMIT_MASK_FIELD_COUNT], -} - -impl CommitMask { - #[allow(clippy::declare_interior_mutable_const)] // template value, never read in place - pub const EMPTY: CommitMask = CommitMask { - mask: [const { Cell::new(0) }; COMMIT_MASK_FIELD_COUNT], - }; - - pub fn clear_all(&self) { - for f in &self.mask { - f.set(0); - } - } - pub fn set_full(&self) { - for f in &self.mask { - f.set(!0); - } - } - pub fn is_empty(&self) -> bool { - self.mask.iter().all(|f| f.get() == 0) - } - pub fn is_full(&self) -> bool { - self.mask.iter().all(|f| f.get() == !0) - } - /// Are all bits of `other` set in `self`? - pub fn all_set(&self, other: &CommitMask) -> bool { - self.mask - .iter() - .zip(&other.mask) - .all(|(a, b)| a.get() & b.get() == b.get()) - } - pub fn any_set(&self, other: &CommitMask) -> bool { - self.mask - .iter() - .zip(&other.mask) - .any(|(a, b)| a.get() & b.get() != 0) - } - pub fn set(&self, other: &CommitMask) { - for (a, b) in self.mask.iter().zip(&other.mask) { - a.set(a.get() | b.get()); - } - } - pub fn clear(&self, other: &CommitMask) { - for (a, b) in self.mask.iter().zip(&other.mask) { - a.set(a.get() & !b.get()); - } - } - pub fn intersect(&self, other: &CommitMask) -> CommitMask { - let r = CommitMask::EMPTY; - for ((a, b), c) in self.mask.iter().zip(&other.mask).zip(&r.mask) { - c.set(a.get() & b.get()); - } - r - } - pub fn copy_from(&self, other: &CommitMask) { - for (a, b) in self.mask.iter().zip(&other.mask) { - a.set(b.get()); - } - } - /// `mi_commit_mask_create`: set `count` bits starting at `bitidx`. - pub fn create(bitidx: usize, bitcount: usize) -> CommitMask { - debug_assert!(bitidx + bitcount <= COMMIT_MASK_BITS); - let cm = CommitMask::EMPTY; - if bitcount == COMMIT_MASK_BITS { - cm.set_full(); - } else if bitcount > 0 { - let mut field = bitidx / COMMIT_MASK_FIELD_BITS; - let mut ofs = bitidx % COMMIT_MASK_FIELD_BITS; - let mut left = bitcount; - while left > 0 { - let avail = COMMIT_MASK_FIELD_BITS - ofs; - let count = left.min(avail); - let mask = if count == COMMIT_MASK_FIELD_BITS { - !0 - } else { - ((1usize << count) - 1) << ofs - }; - cm.mask[field].set(cm.mask[field].get() | mask); - left -= count; - ofs = 0; - field += 1; - } - } - cm - } - /// Raw field words (for the Lean conformance bridge). - pub fn to_words(&self) -> [usize; COMMIT_MASK_FIELD_COUNT] { - core::array::from_fn(|i| self.mask[i].get()) - } - - /// Iterate maximal runs of set bits as `(bit_index, bit_count)`. - pub fn for_each_run(&self, mut f: impl FnMut(usize, usize)) { - let mut idx = 0; - while idx < COMMIT_MASK_BITS { - // find next set bit - while idx < COMMIT_MASK_BITS - && self.mask[idx / COMMIT_MASK_FIELD_BITS].get() - & (1 << (idx % COMMIT_MASK_FIELD_BITS)) - == 0 - { - idx += 1; - } - let start = idx; - while idx < COMMIT_MASK_BITS - && self.mask[idx / COMMIT_MASK_FIELD_BITS].get() - & (1 << (idx % COMMIT_MASK_FIELD_BITS)) - != 0 - { - idx += 1; - } - if idx > start { - f(start, idx - start); - } - } - } -} - -/// Where a segment's memory came from (`mi_memkind_t`). -#[derive(Clone, Copy, Debug)] -pub enum MemSource { - Os, - Arena(crate::arena::ArenaRef), -} - -/// Provenance of segment memory (`mi_memid_t`). -#[derive(Clone, Copy, Debug)] -pub struct MemId { - pub mem: OsMem, - pub source: MemSource, - pub is_pinned: bool, - pub initially_committed: bool, - pub initially_zero: bool, -} - -/// `mi_segment_t`. The struct is followed (within the same allocation) by -/// nothing: `slices` is a fixed array as in C. -#[repr(C)] -pub struct Segment { - pub memid: Cell, - pub allow_decommit: Cell, - pub allow_purge: Cell, - pub segment_size: Cell, - - pub purge_expire: Cell, - pub purge_mask: CommitMask, - pub commit_mask: CommitMask, - - pub next: Cell<*mut Segment>, // intrusive: abandoned list - pub was_reclaimed: Cell, - pub dont_free: Cell, - pub free_is_zero: Cell, - - pub abandoned: Cell, // abandoned pages (<= used) - pub abandoned_visits: Cell, - pub used: Cell, // pages in use - pub cookie: Cell, - - pub abandoned_os_next: Cell<*mut Segment>, - pub abandoned_os_prev: Cell<*mut Segment>, - pub abandoned_os_listed: Cell, - - pub segment_slices: Cell, - pub segment_info_slices: Cell, - - pub kind: Cell, - pub slice_entries: Cell, - pub thread_id: AtomicUsize, - - pub slices: [Slice; SLICES_PER_SEGMENT + 1], -} - -/// The block-area start offset within a page (pure core of -/// `_mi_segment_page_start_from_slice`): nudge the start off OS-page/cache -/// boundaries while keeping it `MAX_ALIGN_SIZE`-aligned and, for blocks up -/// to `MAX_ALIGN_GUARANTEE`, block-size aligned. Modeled and verified in -/// `verify/RimallocVerify/PageStart.lean`; the live function is pinned to -/// the proven contract in `tests/lean_conformance.rs`. -pub(crate) fn page_start_offset(block_size: usize, pstart_addr: usize, psize: usize) -> usize { - let mut start_offset = 0; - if block_size > 0 && block_size <= MAX_ALIGN_GUARANTEE { - let adjust = block_size - (pstart_addr % block_size); - if adjust < block_size && psize >= block_size + adjust { - start_offset += adjust; - } - } - if block_size >= PTR_SIZE { - if block_size <= 64 { - start_offset += 3 * block_size; - } else if block_size <= 512 { - start_offset += block_size; - } - } - align_up(start_offset, MAX_ALIGN_SIZE) -} - -/// `_mi_ptr_segment`: the segment containing `p` (which must point into one). -/// -/// # Safety -/// `p` must point into a live segment (one byte past the start is ok). -#[inline(always)] -pub unsafe fn ptr_segment<'a>(p: *const u8) -> &'a Segment { - debug_assert!(!p.is_null()); - // SAFETY: per contract; align down to the SEGMENT_SIZE boundary - // (one byte before p, for blocks aligned at N*SEGMENT_SIZE). The - // segment mapping's provenance was exposed at allocation. - unsafe { &*ptr::with_exposed_provenance((p.addr() - 1) & !SEGMENT_MASK) } -} - -/// `_mi_ptr_page`: the page containing `p`. -/// -/// # Safety -/// `p` must point into the block area of a live page. -#[inline(always)] -pub unsafe fn ptr_page<'a>(p: *const u8) -> &'a Page { - // SAFETY: per contract. - let segment = unsafe { ptr_segment(p) }; - segment.page_of(p) -} - -impl Segment { - /// `_mi_arena_memid_is_suitable` on this segment's memory. - pub fn memid_suitable(&self, req: crate::arena::ArenaId) -> bool { - match self.memid.get().source { - MemSource::Arena(aref) => crate::arena::ref_is_suitable(aref, req), - MemSource::Os => req == crate::arena::ARENA_ID_NONE, - } - } - - /// Base address of the segment mapping, carrying provenance over the - /// whole (exposed) mapping rather than just the header struct. - #[inline(always)] - pub fn as_ptr(&self) -> *mut u8 { - ptr::with_exposed_provenance_mut(ptr::from_ref(self).addr()) - } - - #[inline(always)] - pub fn size(&self) -> usize { - self.segment_slices.get() * SEGMENT_SLICE_SIZE - } - - #[inline] - pub fn info_size(&self) -> usize { - self.segment_info_slices.get() * SEGMENT_SLICE_SIZE - } - - #[inline(always)] - pub fn is_abandoned(&self) -> bool { - self.thread_id.load(Ordering::Relaxed) == 0 - } - - #[inline] - pub fn slice_index(&self, slice: &Slice) -> usize { - let index = - (ptr::from_ref(slice).addr() - self.slices.as_ptr().addr()) / size_of::(); - debug_assert!(index < self.slice_entries.get()); - index - } - - /// `mi_slice_start`: address of the slice's memory. - #[inline] - pub fn slice_start(&self, slice: &Slice) -> *mut u8 { - // SAFETY: slices map 1:1 onto the segment's memory. - unsafe { - self.as_ptr() - .add(self.slice_index(slice) * SEGMENT_SLICE_SIZE) - } - } - - /// One past the last usable slice entry. - #[inline] - pub fn slices_end(&self) -> *const Slice { - // SAFETY: slice_entries <= SLICES_PER_SEGMENT+1. - unsafe { self.slices.as_ptr().add(self.slice_entries.get()) } - } - - /// `_mi_segment_page_of`: the page holding `p` (interior pointers ok). - #[inline(always)] - pub fn page_of(&self, p: *const u8) -> &Page { - let diff = p.addr() - self.as_ptr().addr(); - debug_assert!(diff > 0 && diff <= SEGMENT_SIZE); - let idx = diff >> SEGMENT_SLICE_SHIFT; - debug_assert!(idx <= self.slice_entries.get()); - let slice = &self.slices[idx]; - let first = slice.slice_first(); - debug_assert!(first.slice_offset.get() == 0); - first - } - - /// `_mi_segment_page_start_from_slice`: start of a page's block area. - /// The start is offset to avoid OS-page/cache alignment effects while - /// staying block-size aligned for blocks up to `MAX_ALIGN_GUARANTEE`. - fn page_start_from_slice(&self, slice: &Slice, block_size: usize) -> (*mut u8, usize) { - let idx = self.slice_index(slice); - let psize = slice.slice_count.get() as usize * SEGMENT_SLICE_SIZE; - let pstart_addr = self.as_ptr().addr() + idx * SEGMENT_SLICE_SIZE; - let start_offset = page_start_offset(block_size, pstart_addr, psize); - // SAFETY: start_offset < psize, within the segment. - let pstart = unsafe { self.as_ptr().add(idx * SEGMENT_SLICE_SIZE + start_offset) }; - (pstart, psize - start_offset) - } - - /// `_mi_segment_page_start` - pub fn page_start(&self, page: &Page) -> (*mut u8, usize) { - self.page_start_from_slice(page, page.block_size.get()) - } - - // ---- commit / purge ---- - - /// `mi_segment_commit_mask` - fn commit_range_mask( - &self, - conservative: bool, - p: *mut u8, - size: usize, - ) -> (*mut u8, usize, CommitMask) { - debug_assert!(self.kind.get() != SegmentKind::Huge); - if size == 0 || size > SEGMENT_SIZE { - return (p, 0, CommitMask::EMPTY); - } - let segstart = self.info_size(); - let segsize = self.size(); - if p.addr() >= self.as_ptr().addr() + segsize { - return (p, 0, CommitMask::EMPTY); - } - let pstart = p.addr() - self.as_ptr().addr(); - debug_assert!(pstart + size <= segsize); - - let (mut start, mut end) = if conservative { - ( - align_up(pstart, COMMIT_SIZE), - align_down(pstart + size, COMMIT_SIZE), - ) - } else { - ( - align_down(pstart, MINIMAL_COMMIT_SIZE), - align_up(pstart + size, MINIMAL_COMMIT_SIZE), - ) - }; - if pstart >= segstart && start < segstart { - start = segstart; - } - end = end.min(segsize); - - let full_size = end.saturating_sub(start); - if full_size == 0 { - return (p, 0, CommitMask::EMPTY); - } - let bitidx = start / COMMIT_SIZE; - let bitcount = full_size / COMMIT_SIZE; - ( - self.as_ptr().with_addr(self.as_ptr().addr() + start), - full_size, - CommitMask::create(bitidx, bitcount), - ) - } - - /// `mi_segment_commit` - fn commit(&self, p: *mut u8, size: usize) -> bool { - let (start, full_size, mask) = self.commit_range_mask(false, p, size); - if mask.is_empty() || full_size == 0 { - return true; - } - if !self.commit_mask.all_set(&mask) { - // SAFETY: range lies within this segment's mapping. - unsafe { os::commit(start, full_size) }; - self.commit_mask.set(&mask); - } - if self.purge_mask.any_set(&mask) { - self.purge_expire - .set(options::clock_now() + options::purge_delay()); - } - self.purge_mask.clear(&mask); - true - } - - /// `mi_segment_ensure_committed` - pub fn ensure_committed(&self, p: *mut u8, size: usize) -> bool { - if self.commit_mask.is_full() && self.purge_mask.is_empty() { - return true; - } - debug_assert!(self.kind.get() != SegmentKind::Huge); - let ok = self.commit(p, size); - debug_assert!( - !ok || { - let (_, fs, mask) = self.commit_range_mask(false, p, size); - fs == 0 || self.commit_mask.all_set(&mask) - }, - "ensure_committed left range uncovered" - ); - ok - } - - /// `mi_segment_purge` - fn purge(&self, p: *mut u8, size: usize) { - if !self.allow_purge.get() { - return; - } - let (start, full_size, mask) = self.commit_range_mask(true, p, size); - if mask.is_empty() || full_size == 0 { - return; - } - if self.commit_mask.any_set(&mask) { - debug_assert!(self.allow_decommit.get()); - if options::purge_decommits() != 0 { - // SAFETY: purged ranges hold no live blocks (callers - // guarantee); decommitted ranges recommit on next use. - unsafe { os::decommit(start, full_size) }; - self.commit_mask.clear(&mask); - } else { - // SAFETY: purged ranges hold no live blocks (callers guarantee). - unsafe { os::reset(start, full_size) }; - } - } - self.purge_mask.clear(&mask); - } - - /// `mi_segment_schedule_purge` - fn schedule_purge(&self, p: *mut u8, size: usize) { - if !self.allow_purge.get() { - return; - } - if options::purge_delay() == 0 { - self.purge(p, size); - return; - } - let (_, full_size, mask) = self.commit_range_mask(true, p, size); - if mask.is_empty() || full_size == 0 { - return; - } - let cmask = self.commit_mask.intersect(&mask); // only purge committed parts - self.purge_mask.set(&cmask); - let now = options::clock_now(); - let expire = self.purge_expire.get(); - if expire == 0 { - self.purge_expire.set(now + options::purge_delay()); - } else if expire <= now { - if expire + options::purge_extend_delay() <= now { - self.try_purge(true); - } else { - self.purge_expire.set(now + options::purge_extend_delay()); - } - } else { - self.purge_expire - .set(expire + options::purge_extend_delay()); - } - } - - /// `mi_segment_try_purge` - pub fn try_purge(&self, force: bool) { - if !self.allow_purge.get() || self.purge_expire.get() == 0 || self.purge_mask.is_empty() { - return; - } - if !force && options::clock_now() < self.purge_expire.get() { - return; - } - let mask = CommitMask::EMPTY; - mask.copy_from(&self.purge_mask); - self.purge_expire.set(0); - self.purge_mask.clear_all(); - mask.for_each_run(|idx, count| { - // SAFETY: bit ranges map within the segment. - let p = unsafe { self.as_ptr().add(idx * COMMIT_SIZE) }; - self.purge(p, count * COMMIT_SIZE); - }); - } -} - -// --------------------------------------------------------------------------- -// Span queues (free slice spans, per thread) -// --------------------------------------------------------------------------- - -/// `mi_span_queue_t` -pub struct SpanQueue { - pub first: Cell<*mut Slice>, - pub last: Cell<*mut Slice>, - pub slice_count: usize, -} - -/// Max slice count per span bin (`MI_SEGMENT_SPAN_QUEUES_EMPTY`). -pub const SPAN_QUEUE_SIZES: [usize; SEGMENT_BIN_MAX + 1] = [ - 1, 1, 2, 3, 4, 5, 6, 7, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, - 192, 224, 256, 320, 384, 448, 512, 640, 768, 896, 1024, -]; - -/// `mi_slice_bin`: bin for a span of `slice_count` slices. -#[inline] -fn slice_bin(slice_count: usize) -> usize { - debug_assert!(slice_count * SEGMENT_SLICE_SIZE <= SEGMENT_SIZE); - if slice_count <= 1 { - return slice_count; - } - let n = slice_count - 1; - let s = (usize::BITS - 1 - n.leading_zeros()) as usize; // bsr, n > 0 - let bin = if s <= 2 { - n + 1 - } else { - ((s << 2) | ((n >> (s - 2)) & 0x03)) - 4 - }; - debug_assert!(bin <= SEGMENT_BIN_MAX); - bin -} - -/// Per-thread segment data (`mi_segments_tld_t`). -pub struct SegmentsTld { - pub spans: [SpanQueue; SEGMENT_BIN_MAX + 1], - pub count: Cell, - pub peak_count: Cell, - pub current_size: Cell, - pub peak_size: Cell, - pub reclaim_count: Cell, -} - -impl SegmentsTld { - pub fn new() -> SegmentsTld { - SegmentsTld { - spans: core::array::from_fn(|i| SpanQueue { - first: Cell::new(ptr::null_mut()), - last: Cell::new(ptr::null_mut()), - slice_count: SPAN_QUEUE_SIZES[i], - }), - count: Cell::new(0), - peak_count: Cell::new(0), - current_size: Cell::new(0), - peak_size: Cell::new(0), - reclaim_count: Cell::new(0), - } - } - - /// `mi_span_queue_for` - fn span_queue_for(&self, slice_count: usize) -> &SpanQueue { - let sq = &self.spans[slice_bin(slice_count)]; - debug_assert!(sq.slice_count >= slice_count); - sq - } - - fn track_size(&self, delta: isize) { - // `committed` is tracked at whole-segment granularity here, the one - // place segments enter and leave. Sub-segment lazy commit and - // decommit-purge are not reflected. - // - // A *globally exact* committed counter remains a rats' nest: freed OS - // segments flow through the global segment cache and the deferred-free - // list as bare `OsMem` records that have already shed the segment's - // `commit_mask`, and `cache_push`/`defer_free`/`cache_purge_expired` - // recommit, reset, and munmap whole regions in contexts that cannot - // cheaply recover the precise committed-byte delta (mprotect/mmap give - // no prior-state feedback). Tracking the true delta there would mean - // threading committed-byte counts back through the cache/defer layer — - // exactly the cross-layer state duplication that produced the original - // underflow. The coarse, whole-segment counter is instead exactly - // balanced by construction: every increase is paired with an equal - // decrease at the same granularity, so it can never underflow, in - // either reset or decommit purge mode (asserted in - // `tests/committed_stat.rs`). - if delta >= 0 { - crate::stats::STATS.segments.increase(1); - crate::stats::STATS.reserved.increase(delta as usize); - crate::stats::STATS.committed.increase(delta as usize); - } else { - crate::stats::STATS.segments.decrease(1); - crate::stats::STATS.reserved.decrease((-delta) as usize); - crate::stats::STATS.committed.decrease((-delta) as usize); - } - let count = self.count.get(); - self.count.set(count.wrapping_add_signed(delta.signum())); - self.peak_count - .set(self.peak_count.get().max(self.count.get())); - let size = self.current_size.get().wrapping_add_signed(delta); - self.current_size.set(size); - self.peak_size.set(self.peak_size.get().max(size)); - } -} - -impl SpanQueue { - /// `mi_span_queue_push`: also marks the slice free (`block_size = 0`). - fn push(&self, slice: &Slice) { - debug_assert!(slice.prev.get().is_null() && slice.next.get().is_null()); - slice.prev.set(ptr::null_mut()); - slice.next.set(self.first.get()); - self.first.set(ptr::from_ref(slice).cast_mut()); - // SAFETY: queue nodes are live slices. - match unsafe { slice.next.get().as_ref() } { - Some(next) => next.prev.set(ptr::from_ref(slice).cast_mut()), - None => self.last.set(ptr::from_ref(slice).cast_mut()), - } - slice.block_size.set(0); // free - } - - /// `mi_span_queue_delete`: also marks the slice used (`block_size = 1`). - fn delete(&self, slice: &Slice) { - debug_assert!(slice.block_size.get() == 0 && slice.slice_count.get() > 0); - // SAFETY: prev/next are live queue nodes. - unsafe { - if let Some(prev) = slice.prev.get().as_ref() { - prev.next.set(slice.next.get()); - } - if ptr::from_ref(slice) == self.first.get() { - self.first.set(slice.next.get()); - } - if let Some(next) = slice.next.get().as_ref() { - next.prev.set(slice.prev.get()); - } - if ptr::from_ref(slice) == self.last.get() { - self.last.set(slice.prev.get()); - } - } - slice.prev.set(ptr::null_mut()); - slice.next.set(ptr::null_mut()); - slice.block_size.set(1); // no longer free - } -} - -// --------------------------------------------------------------------------- -// Span free / coalesce / allocate -// --------------------------------------------------------------------------- - -impl Segment { - /// `mi_segment_span_free`: register `[slice_index, +slice_count)` as a - /// free span (queue it unless huge/abandoned), optionally purging. - fn span_free( - &self, - slice_index: usize, - slice_count: usize, - allow_purge: bool, - tld: &SegmentsTld, - ) { - debug_assert!(slice_index < self.slice_entries.get()); - let queue = (self.kind.get() != SegmentKind::Huge && !self.is_abandoned()) - .then(|| tld.span_queue_for(slice_count)); - let slice_count = slice_count.max(1); - let slice = &self.slices[slice_index]; - slice.slice_count.set(slice_count as u32); - slice.slice_offset.set(0); - if slice_count > 1 { - let last_index = (slice_index + slice_count - 1).min(self.slice_entries.get()); - let last = &self.slices[last_index]; - last.slice_count.set(0); - last.slice_offset - .set((size_of::() * (last_index - slice_index)) as u32); - last.block_size.set(0); - } - if allow_purge { - self.schedule_purge(self.slice_start(slice), slice_count * SEGMENT_SLICE_SIZE); - } - match queue { - Some(sq) => sq.push(slice), - None => slice.block_size.set(0), // mark free anyway - } - } - - /// `mi_segment_span_remove_from_queue` - fn span_remove_from_queue(&self, slice: &Slice, tld: &SegmentsTld) { - debug_assert!(self.kind.get() != SegmentKind::Huge); - tld.span_queue_for(slice.slice_count.get() as usize) - .delete(slice); - } - - /// `mi_segment_span_free_coalesce`: free a span, merging with free - /// neighbors. Returns the (possibly extended) span head. - pub fn span_free_coalesce<'a>(&'a self, slice: &'a Slice, tld: &SegmentsTld) -> &'a Slice { - debug_assert!(slice.slice_count.get() > 0 && slice.slice_offset.get() == 0); - if self.kind.get() == SegmentKind::Huge { - slice.block_size.set(0); - return slice; - } - let is_abandoned = self.is_abandoned(); - let mut slice_count = slice.slice_count.get() as usize; - let idx = self.slice_index(slice); - let mut head_idx = idx; - let next_idx = idx + slice_count; - if next_idx < self.slice_entries.get() { - let next = &self.slices[next_idx]; - if next.block_size.get() == 0 { - slice_count += next.slice_count.get() as usize; - if !is_abandoned { - self.span_remove_from_queue(next, tld); - } - } - } - if idx > 0 { - let prev = self.slices[idx - 1].slice_first(); - if prev.block_size.get() == 0 { - slice_count += prev.slice_count.get() as usize; - let prev_idx = self.slice_index(prev); - slice.slice_count.set(0); - slice - .slice_offset - .set((size_of::() * (idx - prev_idx)) as u32); - if !is_abandoned { - self.span_remove_from_queue(prev, tld); - } - head_idx = prev_idx; - } - } - self.span_free(head_idx, slice_count, true, tld); - &self.slices[head_idx] - } - - /// `mi_segment_span_allocate`: turn a free span into a page. - /// Returns `None` if committing failed. - fn span_allocate(&self, slice_index: usize, slice_count: usize) -> Option<&Page> { - let slice = &self.slices[slice_index]; - debug_assert!(slice.block_size.get() <= 1); - let (start, _) = self.page_start_from_slice(slice, 0); - if !self.ensure_committed(start, slice_count * SEGMENT_SLICE_SIZE) { - return None; - } - slice.slice_offset.set(0); - slice.slice_count.set(slice_count as u32); - let bsize = slice_count * SEGMENT_SLICE_SIZE; - slice.block_size.set(bsize); - - // Back-offsets for interior pointer lookup: first - // MAX_SLICE_OFFSET_COUNT entries and the last one (for coalescing). - let mut extra = (slice_count - 1).min(MAX_SLICE_OFFSET_COUNT); - if slice_index + extra >= self.slice_entries.get() { - extra = self.slice_entries.get() - slice_index - 1; - } - for i in 1..=extra { - let s = &self.slices[slice_index + i]; - s.slice_offset.set((size_of::() * i) as u32); - s.slice_count.set(0); - s.block_size.set(1); - } - let last_index = (slice_index + slice_count - 1).min(self.slice_entries.get()); - if last_index > slice_index { - let last = &self.slices[last_index]; - last.slice_offset - .set((size_of::() * (last_index - slice_index)) as u32); - last.slice_count.set(0); - last.block_size.set(1); - } - - let page = slice; - page.is_committed.set(true); - page.is_zero_init.set(self.free_is_zero.get()); - page.is_huge.set(self.kind.get() == SegmentKind::Huge); - self.used.set(self.used.get() + 1); - Some(page) - } - - /// `mi_segment_slice_split`: split a used span, freeing the tail. - fn slice_split(&self, slice: &Slice, slice_count: usize, tld: &SegmentsTld) { - debug_assert!(slice.slice_count.get() as usize >= slice_count); - debug_assert!(slice.block_size.get() > 0); - if slice.slice_count.get() as usize <= slice_count { - return; - } - debug_assert!(self.kind.get() != SegmentKind::Huge); - let next_index = self.slice_index(slice) + slice_count; - let next_count = slice.slice_count.get() as usize - slice_count; - self.span_free(next_index, next_count, false, tld); - slice.slice_count.set(slice_count as u32); - } -} - -/// `mi_segments_page_find_and_allocate`: best-fit search in the span queues. -fn page_find_and_allocate<'a>( - slice_count: usize, - req_arena: crate::arena::ArenaId, - tld: &SegmentsTld, -) -> Option<&'a Page> { - debug_assert!(slice_count * SEGMENT_SLICE_SIZE <= LARGE_OBJ_SIZE_MAX); - let start_bin = slice_bin(slice_count); - let slice_count = slice_count.max(1); - for sq in &tld.spans[start_bin..] { - let mut p = sq.first.get(); - // SAFETY: queue nodes are live slices in live segments. - while let Some(slice) = unsafe { p.as_ref() } { - if slice.slice_count.get() as usize >= slice_count - && slice.segment().memid_suitable(req_arena) - { - let segment = slice.segment(); - sq.delete(slice); - if slice.slice_count.get() as usize > slice_count { - segment.slice_split(slice, slice_count, tld); - } - debug_assert!( - slice.slice_count.get() as usize == slice_count && slice.block_size.get() > 0 - ); - let index = segment.slice_index(slice); - return match segment.span_allocate(index, slice_count) { - Some(page) => Some(page), - None => { - // commit failed; restore the slice - segment.span_free_coalesce(slice, tld); - None - } - }; - } - p = slice.next.get(); - } - } - None -} - -// --------------------------------------------------------------------------- -// Segment allocation -// --------------------------------------------------------------------------- - -/// `mi_segment_calculate_slices` -fn calculate_slices(mut required: usize) -> (usize, usize) { - let page_size = os::page_size(); - let mut isize_ = align_up(size_of::(), page_size); - let mut guardsize = 0; - if cfg!(feature = "secure") { - // a protected page between the segment info and the page data, - // and one at the end of the segment - guardsize = page_size; - if required > 0 { - required = align_up(required, SEGMENT_SLICE_SIZE) + page_size; - } - } - isize_ = align_up(isize_ + guardsize, SEGMENT_SLICE_SIZE); - let info_slices = isize_ / SEGMENT_SLICE_SIZE; - let segment_size = if required == 0 { - SEGMENT_SIZE - } else { - align_up(required + isize_ + guardsize, SEGMENT_SLICE_SIZE) - }; - (segment_size / SEGMENT_SLICE_SIZE, info_slices) -} - -/// `mi_segment_os_alloc` (direct OS allocation; arenas come later). -fn segment_os_alloc<'a>( - page_alignment: usize, - mut segment_slices: usize, - info_slices: usize, - commit: bool, - required: usize, - req_arena: crate::arena::ArenaId, - tld: &SegmentsTld, -) -> Option<(&'a Segment, usize)> { - let mut alignment = SEGMENT_ALIGN; - let mut align_offset = 0; - if page_alignment > 0 { - debug_assert!(page_alignment >= SEGMENT_ALIGN); - alignment = page_alignment; - let info_size = info_slices * SEGMENT_SLICE_SIZE; - align_offset = align_up(info_size, SEGMENT_ALIGN); - let extra = align_offset - info_size; - (segment_slices, _) = calculate_slices(required + extra); - } - let segment_size = segment_slices * SEGMENT_SLICE_SIZE; - // Prefer the arenas for standard-aligned allocations (syscall-free - // reuse, batched purging); fall back to the OS segment cache / mmap. - // Heaps bound to a specific arena always try it, rounding odd huge - // sizes up to whole blocks. - if align_offset == 0 - && alignment == SEGMENT_ALIGN - && (segment_size.is_multiple_of(crate::arena::ARENA_BLOCK_SIZE) - || req_arena != crate::arena::ARENA_ID_NONE) - && let Some((p, is_zero, aref)) = crate::arena::alloc( - segment_size.div_ceil(crate::arena::ARENA_BLOCK_SIZE), - req_arena, - ) - { - let mem = OsMem { - base: p, - size: segment_size, - align: alignment, - is_committed: true, - is_zero, - }; - if !is_zero { - // SAFETY: the header is zeroed below, before the reference is used. - unsafe { ptr::write_bytes(p, 0, size_of::()) }; - } - // SAFETY: aligned, committed, header zeroed/zero. - let segment: &Segment = unsafe { &*p.cast() }; - segment.commit_mask.set_full(); - segment.memid.set(MemId { - mem, - source: MemSource::Arena(aref), - is_pinned: false, - initially_committed: true, - initially_zero: is_zero, - }); - segment.allow_decommit.set(true); - segment.allow_purge.set(options::purge_delay() >= 0); - segment.segment_size.set(segment_size); - segment.purge_expire.set(0); - segment.purge_mask.clear_all(); - segment.free_is_zero.set(is_zero); - tld.track_size(segment_size as isize); - return Some((segment, segment_slices)); - } - // A heap bound to a specific arena must never fall back to the OS - // (mirrors `_mi_arena_alloc_aligned`, which fails with ENOMEM when an - // arena id is requested): pages of an OS segment are not - // `memid_suitable` for such a heap, so `segments_page_alloc` would loop - // forever allocating segments it can never use — and a huge page would - // silently escape the reservation. - if req_arena != crate::arena::ARENA_ID_NONE { - return None; - } - // Allocate so that `base + align_offset` is `alignment`-aligned. - let (p, mem) = if align_offset == 0 { - if segment_size == SEGMENT_SIZE - && alignment == SEGMENT_ALIGN - && let Some(cached) = os::cache_pop() - { - cached - } else { - OsMem::alloc_aligned(segment_size, alignment, commit)? - } - } else { - let (p, mem) = OsMem::alloc_aligned(segment_size + alignment, alignment, commit)?; - // SAFETY: offset stays inside the over-allocated mapping. - (unsafe { p.add(alignment - align_offset) }, mem) - }; - let commit_needed = (info_slices * SEGMENT_SLICE_SIZE).div_ceil(COMMIT_SIZE); - if !mem.is_committed { - // SAFETY: committing the info prefix of our fresh mapping. - unsafe { os::commit(p, commit_needed * COMMIT_SIZE) }; - } - // Zero the whole header + slices metadata before creating the &Segment: - // reused regions hold stale or partially-uninitialized bytes, `Cell::set` - // reads the old value, and writing through the raw pointer *after* - // taking the reference would invalidate it. The all-zero pattern is - // valid for every field. (Fresh OS memory is already zero.) - if !mem.is_zero { - // SAFETY: the info area is committed and covers the Segment struct. - unsafe { ptr::write_bytes(p, 0, size_of::()) }; - } - // SAFETY: header initialized just above; slice metadata is initialized - // by `segment_alloc` before use. - let segment: &Segment = unsafe { &*p.cast() }; - if mem.is_committed { - segment.commit_mask.set_full(); - } else { - segment - .commit_mask - .copy_from(&CommitMask::create(0, commit_needed)); - } - segment.memid.set(MemId { - mem, - source: MemSource::Os, - is_pinned: false, - initially_committed: mem.is_committed, - initially_zero: mem.is_zero, - }); - segment.allow_decommit.set(true); - segment.allow_purge.set(options::purge_delay() >= 0); - segment.segment_size.set(segment_size); - segment.purge_expire.set(0); - segment.purge_mask.clear_all(); - segment.free_is_zero.set(mem.is_zero); - tld.track_size(segment_size as isize); - Some((segment, segment_slices)) -} - -/// `mi_segment_alloc`: allocate a fresh segment; if `required > 0` it is a -/// huge segment and the huge page is returned too. -fn segment_alloc<'a>( - required: usize, - page_alignment: usize, - req_arena: crate::arena::ArenaId, - tld: &SegmentsTld, -) -> Option<(&'a Segment, Option<&'a Page>)> { - let (segment_slices, info_slices) = calculate_slices(required); - let eager_delay = crate::init::current_thread_count() > 1 - && tld.peak_count.get() < options::eager_commit_delay() as usize; - let eager = !eager_delay && options::eager_commit() == 1; - let commit = eager || required > 0; - - let (segment, segment_slices) = segment_os_alloc( - page_alignment, - segment_slices, - info_slices, - commit, - required, - req_arena, - tld, - )?; - - let mut slice_entries = segment_slices.min(SLICES_PER_SEGMENT); - let mut guard_slices = 0; - if cfg!(feature = "secure") { - // Guard page between the segment metadata and the page data, and - // one at the very end of the segment; a trailing slice is - // sacrificed so the guard never overlaps usable block area. - let ps = os::page_size(); - let info_size = info_slices * SEGMENT_SLICE_SIZE; - let seg_size = segment_slices * SEGMENT_SLICE_SIZE; // true size (huge > 32MiB) - // SAFETY: both pages are inside our mapping; the end page is - // committed first if the segment was reserved lazily. - unsafe { - os::protect(segment.as_ptr().add(info_size - ps), ps); - let end = segment.as_ptr().add(seg_size - ps); - if !commit { - os::commit(end, ps); - } - os::protect(end, ps); - } - if slice_entries == segment_slices { - slice_entries -= 1; // don't use the last slice - } - guard_slices = 1; - } - segment.segment_slices.set(segment_slices); - segment.segment_info_slices.set(info_slices); - segment - .thread_id - .store(crate::init::thread_id(), Ordering::Release); - segment - .cookie - .set(segment.as_ptr().addr() ^ 0xa5a5_5a5a_1234_5678); - segment.slice_entries.set(slice_entries); - segment.kind.set(if required == 0 { - SegmentKind::Normal - } else { - SegmentKind::Huge - }); - - // Reserve the first slices for the segment info. - let page0 = segment.span_allocate(0, info_slices)?; - debug_assert!(ptr::eq(page0, &segment.slices[0])); - segment.used.set(0); // info slices don't count toward usage - - if segment.kind.get() == SegmentKind::Normal { - segment.span_free(info_slices, slice_entries - info_slices, false, tld); - Some((segment, None)) - } else { - let huge = - segment.span_allocate(info_slices, segment_slices - info_slices - guard_slices)?; - Some((segment, Some(huge))) - } -} - -/// `mi_segment_os_free` -fn segment_os_free(segment: &Segment, tld: &SegmentsTld) { - segment.thread_id.store(0, Ordering::Relaxed); - tld.track_size(-(segment.size() as isize)); - if segment.was_reclaimed.get() { - tld.reclaim_count.set(tld.reclaim_count.get() - 1); - segment.was_reclaimed.set(false); - } - if cfg!(feature = "secure") { - // Remove the guard pages so the memory can be recycled. - let ps = os::page_size(); - // SAFETY: the same ranges protected in segment_alloc. - unsafe { - os::unprotect(segment.as_ptr().add(segment.info_size() - ps), ps); - os::unprotect(segment.as_ptr().add(segment.size() - ps), ps); - } - } - let memid = segment.memid.get(); - match memid.source { - MemSource::Arena(aref) => { - // Returning blocks only flips bitmap bits — no dealloc happens - // while references into the segment are still on the stack. - crate::arena::free(aref, segment.commit_mask.is_full()); - } - MemSource::Os => { - // Cache standard segments for syscall-free reuse (the mapping - // stays committed: stat-neutral until eviction); otherwise - // defer the unmap since references into the segment may still - // be on the call stack. - let mem = memid.mem; - let standard = mem.size == SEGMENT_SIZE && mem.base.addr() & SEGMENT_MASK == 0; - if !os::cache_push(mem, standard) { - os::defer_free(mem); - } - } - } -} - -/// `mi_segment_free` -fn segment_free(segment: &Segment, tld: &SegmentsTld) { - debug_assert!(segment.used.get() == 0); - if segment.dont_free.get() { - return; - } - // Remove remaining free spans from the queues. - let mut p = segment.slices.as_ptr(); - while p < segment.slices_end() { - // SAFETY: span heads partition the slices array. - let slice = unsafe { &*p }; - debug_assert!(slice.slice_count.get() > 0 && slice.slice_offset.get() == 0); - if slice.block_size.get() == 0 && segment.kind.get() != SegmentKind::Huge { - segment.span_remove_from_queue(slice, tld); - } - // SAFETY: stays within slices array bounds. - p = unsafe { p.add(slice.slice_count.get() as usize) }; - } - segment_os_free(segment, tld); -} - -// --------------------------------------------------------------------------- -// Page free / abandonment -// --------------------------------------------------------------------------- - -impl Segment { - /// `mi_segment_page_clear`: release a fully-free page back to the segment. - fn page_clear<'a>(&'a self, page: &'a Page, tld: &SegmentsTld) -> &'a Slice { - debug_assert!(page.all_free()); - debug_assert!(self.used.get() > 0); - - // Zero the page metadata (but keep segment fields and the heap tag). - page.is_zero_init.set(false); - let heap_tag = page.heap_tag.get(); - page.capacity.set(0); - page.reserved.set(0); - page.set_in_full(false); - page.set_has_aligned(false); - page.free_is_zero.set(false); - page.retire_expire.set(0); - page.free.set(ptr::null_mut()); - page.local_free.set(ptr::null_mut()); - page.used.set(0); - page.block_size_shift.set(0); - page.block_size.set(1); - page.block_recip.set(0); - page.heap_tag.set(heap_tag); - page.page_start.set(ptr::null_mut()); - page.xthread_free.store(0, Ordering::Relaxed); - page.xheap.store(0, Ordering::Relaxed); - page.next.set(ptr::null_mut()); - page.prev.set(ptr::null_mut()); - - crate::stats::STATS.pages.decrease(1); - let slice = self.span_free_coalesce(page, tld); - self.used.set(self.used.get() - 1); - self.free_is_zero.set(false); - slice - } - - /// Debug validator (port of `mi_segment_is_valid`), asserting the - /// Lean-proven span invariants (`Spans.lean`) on a live segment: - /// span heads partition the slice array exactly (`norm_total`), all - /// spans are non-empty (`norm_wf`), no two adjacent free spans exist - /// (`norm_coalesced`), and back-offsets let interior pointers and - /// coalescing find span heads. - #[cfg(debug_assertions)] - pub fn assert_valid(&self) { - if self.kind.get() != SegmentKind::Normal { - return; // huge layouts intentionally exceed `slice_entries` - } - let entries = self.slice_entries.get(); - let mut idx = 0; - let mut total = 0; - let mut prev_free = false; - while idx < entries { - let head = &self.slices[idx]; - let count = head.slice_count.get() as usize; - debug_assert!(count > 0, "empty span at {idx}"); - debug_assert!( - head.slice_offset.get() == 0, - "span head with offset at {idx}" - ); - let is_free = head.block_size.get() == 0; - debug_assert!( - !(prev_free && is_free), - "adjacent free spans at {idx} (coalescing incomplete)" - ); - prev_free = is_free; - // back-offsets: interior entries point at the head - let last = (idx + count - 1).min(entries - 1); - if is_free { - if last > idx { - let l = &self.slices[last]; - debug_assert!( - l.slice_offset.get() as usize == size_of::() * (last - idx), - "free-span tail back-offset wrong at {last}" - ); - } - } else { - let extra = (count - 1) - .min(MAX_SLICE_OFFSET_COUNT) - .min(entries - idx - 1); - for i in 1..=extra { - let e = &self.slices[idx + i]; - debug_assert!( - e.slice_offset.get() as usize == size_of::() * i - && e.slice_count.get() == 0, - "used-span back-offset wrong at {}", - idx + i - ); - } - } - total += count; - idx += count; - } - debug_assert!( - total == entries, - "span partition broken: covers {total} of {entries} slices" - ); - } - - /// `_mi_segment_page_free` - pub fn page_free(&self, page: &Page, _force: bool, tld: &SegmentsTld) { - #[cfg(debug_assertions)] - self.assert_valid(); - self.page_clear(page, tld); - if self.used.get() == 0 { - segment_free(self, tld); - } else if self.used.get() == self.abandoned.get() { - self.abandon(tld); - } else { - self.try_purge(false); - } - } - - /// `mi_segment_abandon`: all pages are abandoned; move the segment to - /// the global abandoned list. - fn abandon(&self, tld: &SegmentsTld) { - debug_assert!(self.used.get() == self.abandoned.get() && self.used.get() > 0); - debug_assert!(self.abandoned_visits.get() == 0); - - // Remove the free spans from the span queues (they stay marked free). - let mut p = self.slices.as_ptr(); - while p < self.slices_end() { - // SAFETY: span heads partition the slices array. - let slice = unsafe { &*p }; - if slice.block_size.get() == 0 && self.kind.get() != SegmentKind::Huge { - self.span_remove_from_queue(slice, tld); - slice.block_size.set(0); // keep it free - } - // SAFETY: stays in bounds. - p = unsafe { p.add(slice.slice_count.get() as usize) }; - } - - self.try_purge(options::abandoned_page_purge() == 1); - - crate::stats::STATS - .segments_abandoned - .fetch_add(1, core::sync::atomic::Ordering::Relaxed); - tld.track_size(-(self.size() as isize)); - self.thread_id.store(0, Ordering::Release); - self.abandoned_visits.set(1); - if self.was_reclaimed.get() { - tld.reclaim_count.set(tld.reclaim_count.get() - 1); - self.was_reclaimed.set(false); - } - abandoned::mark(self); - } - - /// `_mi_segment_page_abandon` - pub fn page_abandon(&self, page: &Page, tld: &SegmentsTld) { - debug_assert!(page.heap().is_null()); - self.abandoned.set(self.abandoned.get() + 1); - debug_assert!(self.abandoned.get() <= self.used.get()); - if self.used.get() == self.abandoned.get() { - self.abandon(tld); - } - let _ = page; - } -} - -/// Global registry of abandoned segments (stand-in for the arena bitmaps + -/// subprocess OS-list of mimalloc; all our segments are OS-allocated). -pub mod abandoned { - use super::Segment; - use core::ptr; - use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - struct List { - lock: AtomicBool, - first: core::cell::Cell<*mut Segment>, - last: core::cell::Cell<*mut Segment>, - } - // SAFETY: all access happens under `lock`. - unsafe impl Sync for List {} - - pub static RECLAIM_TRIES: AtomicUsize = AtomicUsize::new(0); - pub static RECLAIM_HITS: AtomicUsize = AtomicUsize::new(0); - - static LIST: List = List { - lock: AtomicBool::new(false), - first: core::cell::Cell::new(ptr::null_mut()), - last: core::cell::Cell::new(ptr::null_mut()), - }; - static COUNT: AtomicUsize = AtomicUsize::new(0); - - pub fn count() -> usize { - COUNT.load(Ordering::Relaxed) - } - - #[cfg(not(windows))] - pub(crate) fn lock_for_fork() { - crate::sync::spin_acquire(&LIST.lock); - } - #[cfg(not(windows))] - pub(crate) fn unlock_for_fork() { - crate::sync::spin_release(&LIST.lock); - } - - fn locked(f: impl FnOnce() -> R) -> R { - crate::sync::spin_locked(&LIST.lock, f) - } - - /// `_mi_arena_segment_mark_abandoned` (OS-list flavor): push at the end. - pub fn mark(segment: &Segment) { - locked(|| { - link_last(segment); - }); - COUNT.fetch_add(1, Ordering::Relaxed); - } - - /// Append `segment` at the tail. Lock must be held. - fn link_last(segment: &Segment) { - segment.abandoned_os_next.set(ptr::null_mut()); - segment.abandoned_os_prev.set(LIST.last.get()); - segment.abandoned_os_listed.set(true); - // SAFETY: list nodes are live abandoned segments. - match unsafe { LIST.last.get().as_ref() } { - Some(last) => last - .abandoned_os_next - .set(ptr::from_ref(segment).cast_mut()), - None => LIST.first.set(ptr::from_ref(segment).cast_mut()), - } - LIST.last.set(ptr::from_ref(segment).cast_mut()); - } - - fn unlink(segment: &Segment) { - // SAFETY: list nodes are live abandoned segments. - unsafe { - match segment.abandoned_os_prev.get().as_ref() { - Some(prev) => prev.abandoned_os_next.set(segment.abandoned_os_next.get()), - None => LIST.first.set(segment.abandoned_os_next.get()), - } - match segment.abandoned_os_next.get().as_ref() { - Some(next) => next.abandoned_os_prev.set(segment.abandoned_os_prev.get()), - None => LIST.last.set(segment.abandoned_os_prev.get()), - } - } - segment.abandoned_os_next.set(ptr::null_mut()); - segment.abandoned_os_prev.set(ptr::null_mut()); - segment.abandoned_os_listed.set(false); - COUNT.fetch_sub(1, Ordering::Relaxed); - } - - /// `_mi_arena_segment_clear_abandoned`: atomically un-abandon a specific - /// segment. Returns false if some other thread claimed it first. - pub fn clear(segment: &Segment) -> bool { - locked(|| { - let on_list = segment.abandoned_os_listed.get(); - if on_list { - unlink(segment); - } - on_list - }) - } - - /// Pop the first abandoned segment, if any. - pub fn pop<'a>() -> Option<&'a Segment> { - if COUNT.load(Ordering::Relaxed) == 0 { - return None; - } - locked(|| { - // SAFETY: list nodes are live abandoned segments. - let segment = unsafe { LIST.first.get().as_ref() }?; - unlink(segment); - Some(segment) - }) - } - - /// Detach up to `n` segments from the head in a single lock acquisition. - /// Returns a chain linked through `abandoned_os_next`; every node is off - /// the list (`clear` on it returns false) and exclusively ours. - pub fn take<'a>(n: usize) -> Option<&'a Segment> { - if n == 0 || COUNT.load(Ordering::Relaxed) == 0 { - return None; - } - let (head, taken) = locked(|| { - let head = LIST.first.get(); - // SAFETY: list nodes are live abandoned segments (lock held). - let Some(mut tail) = (unsafe { head.as_ref() }) else { - return (head, 0); - }; - let mut taken = 1usize; - tail.abandoned_os_prev.set(ptr::null_mut()); - tail.abandoned_os_listed.set(false); - while taken < n - // SAFETY: list nodes are live abandoned segments (lock held). - && let Some(next) = unsafe { tail.abandoned_os_next.get().as_ref() } - { - tail = next; - tail.abandoned_os_prev.set(ptr::null_mut()); - tail.abandoned_os_listed.set(false); - taken += 1; - } - let rest = tail.abandoned_os_next.get(); - tail.abandoned_os_next.set(ptr::null_mut()); - LIST.first.set(rest); - // SAFETY: as above. - match unsafe { rest.as_ref() } { - Some(r) => r.abandoned_os_prev.set(ptr::null_mut()), - None => LIST.last.set(ptr::null_mut()), - } - (head, taken) - }); - if taken > 0 { - COUNT.fetch_sub(taken, Ordering::Relaxed); - } - // SAFETY: chain head is a live abandoned segment owned by us. - unsafe { head.as_ref() } - } - - /// Re-abandon a chain of `count` segments (linked via `abandoned_os_next`, - /// as built by the caller) in a single lock acquisition. - pub fn splice(head: *mut Segment, count: usize) { - if head.is_null() { - return; - } - locked(|| { - let mut p = head; - // SAFETY: chain nodes are live abandoned segments owned by the - // caller until spliced (lock held). - while let Some(segment) = unsafe { p.as_ref() } { - let next = segment.abandoned_os_next.get(); - link_last(segment); - p = next; - } - }); - COUNT.fetch_add(count, Ordering::Relaxed); - } -} - -// --------------------------------------------------------------------------- -// Reclaim -// --------------------------------------------------------------------------- - -/// Iterate span heads, skipping the initial info span. -fn slices_start_iterate(segment: &Segment) -> (*const Slice, *const Slice) { - let slice = segment.slices.as_ptr(); - // SAFETY: first span is the info span; its count is in bounds. - let start = unsafe { slice.add((*slice).slice_count.get() as usize) }; - (start, segment.slices_end()) -} - -/// `mi_segment_check_free`: collect frees; report if a usable page/span exists. -fn segment_check_free( - segment: &Segment, - slices_needed: usize, - block_size: usize, - tld: &SegmentsTld, -) -> bool { - debug_assert!(segment.is_abandoned()); - let mut has_page = false; - let (mut p, end) = slices_start_iterate(segment); - while p < end { - // SAFETY: span heads partition the slices array. - let mut slice = unsafe { &*p }; - if slice.slice_is_used() { - let page = slice; - crate::page_ops::page_free_collect(page, false); - if page.all_free() { - segment.abandoned.set(segment.abandoned.get() - 1); - slice = segment.page_clear(page, tld); - if slice.slice_count.get() as usize >= slices_needed { - has_page = true; - } - } else if page.block_size() == block_size && page.has_any_available() { - has_page = true; - } - } else if slice.slice_count.get() as usize >= slices_needed { - has_page = true; - } - let next = segment.slice_index(slice) + slice.slice_count.get() as usize; - // SAFETY: span heads stay within the slices array. - p = unsafe { segment.slices.as_ptr().add(next) }; - } - has_page -} - -/// `mi_segment_reclaim`: take ownership of an abandoned segment. -/// Returns `None` if the segment was freed entirely. -fn segment_reclaim<'a>( - segment: &'a Segment, - heap: &Heap, - requested_block_size: usize, - mut right_page_reclaimed: Option<&mut bool>, - tld: &SegmentsTld, -) -> Option<&'a Segment> { - if let Some(flag) = right_page_reclaimed.as_deref_mut() { - *flag = false; - } - segment - .thread_id - .store(crate::init::thread_id(), Ordering::Release); - segment.abandoned_visits.set(0); - segment.was_reclaimed.set(true); - tld.reclaim_count.set(tld.reclaim_count.get() + 1); - tld.track_size(segment.size() as isize); - debug_assert!(segment.next.get().is_null()); - - let (mut p, end) = slices_start_iterate(segment); - while p < end { - // SAFETY: span heads partition the slices array. - let mut slice = unsafe { &*p }; - if slice.slice_is_used() { - let page = slice; - debug_assert!(page.is_committed.get()); - debug_assert!(page.heap().is_null()); - segment.abandoned.set(segment.abandoned.get() - 1); - page.set_heap(ptr::from_ref(heap).cast_mut()); - crate::page_ops::page_use_delayed_free( - page, - crate::page::Delayed::UseDelayedFree, - true, - ); - crate::page_ops::page_free_collect(page, false); - if page.all_free() { - slice = segment.page_clear(page, tld); - } else { - crate::page_ops::page_reclaim(heap, page); - if requested_block_size == page.block_size() - && page.has_any_available() - && let Some(flag) = right_page_reclaimed.as_deref_mut() - { - *flag = true; - } - } - } else { - slice = segment.span_free_coalesce(slice, tld); - } - let next = segment.slice_index(slice) + slice.slice_count.get() as usize; - // SAFETY: span heads stay within the slices array. - p = unsafe { segment.slices.as_ptr().add(next) }; - } - - debug_assert!(segment.abandoned.get() == 0); - if segment.used.get() == 0 { - segment_free(segment, tld); - None - } else { - Some(segment) - } -} - -/// `_mi_segment_attempt_reclaim`: called on a cross-thread free into an -/// abandoned segment. -pub fn attempt_reclaim(heap: &Heap, segment: &Segment) -> bool { - if !segment.is_abandoned() || !segment.memid_suitable(heap.arena_id.get()) { - return false; - } - let target = options::target_segments_per_thread(); - let tld = heap.segments_tld(); - if target > 0 && target as usize <= tld.count.get() { - return false; - } - if abandoned::clear(segment) { - segment_reclaim(segment, heap, 0, None, tld).is_some() - } else { - false - } -} - -/// `_mi_abandoned_reclaim_all` -pub fn reclaim_all(heap: &Heap, tld: &SegmentsTld) { - while let Some(segment) = abandoned::pop() { - segment_reclaim(segment, heap, 0, None, tld); - } -} - -/// `mi_segment_try_reclaim` -fn try_reclaim<'a>( - heap: &Heap, - needed_slices: usize, - block_size: usize, - reclaimed: &mut bool, - tld: &SegmentsTld, -) -> Option<&'a Segment> { - *reclaimed = false; - let mut max_tries = { - // ~10% of the abandoned count, clamped to [8, 1024]. - let perc = options::max_segment_reclaim().clamp(0, 100) as usize; - let total = abandoned::count(); - if perc == 0 || total == 0 { - return None; - } - let relative = (total * perc) / 100; - let tries = relative.clamp(1, 1024); - if tries < 8 && total > 8 { 8 } else { tries } - }; - while max_tries > 0 { - max_tries -= 1; - let target = options::target_segments_per_thread().clamp(0, 1024) as usize; - if target != 0 && tld.count.get() >= target { - break; - } - let Some(segment) = abandoned::pop() else { - break; - }; - abandoned::RECLAIM_TRIES.fetch_add(1, Ordering::Relaxed); - segment - .abandoned_visits - .set(segment.abandoned_visits.get() + 1); - let has_page = segment_check_free(segment, needed_slices, block_size, tld); - if segment.used.get() == 0 { - segment_reclaim(segment, heap, 0, None, tld); - } else if has_page { - abandoned::RECLAIM_HITS.fetch_add(1, Ordering::Relaxed); - return segment_reclaim(segment, heap, block_size, Some(reclaimed), tld); - } else if segment.abandoned_visits.get() > 3 { - segment_reclaim(segment, heap, 0, None, tld); - } else { - max_tries += 1; // not a real try - segment.try_purge(false); - abandoned::mark(segment); - } - } - None -} - -/// `_mi_abandoned_collect` -pub fn abandoned_collect(heap: &Heap, force: bool, tld: &SegmentsTld) { - // One pass over the segments abandoned at entry (the C version's arena - // cursor visits each at most once); re-pushed segments are not revisited. - // The whole batch is detached in one lock acquisition and survivors are - // re-abandoned in one more, instead of locking twice per segment. - let max_tries = if force { - abandoned::count() - } else { - abandoned::count().min(1024) - }; - let mut p = abandoned::take(max_tries); - let mut keep_head: *mut Segment = ptr::null_mut(); - let mut keep_tail: *mut Segment = ptr::null_mut(); - let mut keep_count = 0usize; - while let Some(segment) = p { - // SAFETY: the chain was detached by `take`; read the link before the - // segment is potentially freed below. - p = unsafe { segment.abandoned_os_next.get().as_ref() }; - segment.abandoned_os_next.set(ptr::null_mut()); - segment_check_free(segment, 0, 0, tld); - if segment.used.get() == 0 { - segment_reclaim(segment, heap, 0, None, tld); - } else { - segment.try_purge(force); - let sp = ptr::from_ref(segment).cast_mut(); - if keep_tail.is_null() { - keep_head = sp; - } else { - // SAFETY: keep_tail is a live segment owned by this chain. - unsafe { (*keep_tail).abandoned_os_next.set(sp) }; - } - keep_tail = sp; - keep_count += 1; - } - } - abandoned::splice(keep_head, keep_count); -} - -// --------------------------------------------------------------------------- -// Page allocation entry points -// --------------------------------------------------------------------------- - -/// `mi_segment_force_abandon`: abandon every used page of `segment` so the -/// segment becomes reclaimable by other threads. The pages may be freed in -/// the process; `dont_free` keeps the segment metadata valid throughout. -fn segment_force_abandon(segment: &Segment) { - debug_assert!(!segment.is_abandoned()); - segment.dont_free.set(true); - let (start, end) = slices_start_iterate(segment); - let mut p = start; - while p < end { - // SAFETY: span heads partition the slices array. - let mut slice = unsafe { &*p }; - if slice.slice_is_used() { - let page = slice.slice_first(); - crate::page_ops::page_free_collect(page, false); - if segment.used.get() == segment.abandoned.get() + 1 { - // last in-use page: after abandoning it the segment itself - // is abandoned and must not be touched again. - segment.dont_free.set(false); - crate::page_ops::page_force_abandon(page); - return; - } - crate::page_ops::page_force_abandon(page); - // the page may have been freed and coalesced backward; restart - // from the (possibly merged) span head before advancing. - slice = slice.slice_first(); - } - let next = segment.slice_index(slice) + slice.slice_count.get() as usize; - // SAFETY: span heads stay within the slices array. - p = unsafe { segment.slices.as_ptr().add(next) }; - } - segment.dont_free.set(false); -} - -/// `mi_segments_try_abandon`: when over the per-thread segment target, -/// abandon full-bin segments to increase cross-thread reuse. -fn segments_try_abandon(heap: &Heap, tld: &SegmentsTld) { - let target = options::target_segments_per_thread(); - if target <= 0 || tld.count.get() < target as usize { - return; - } - let target = target as usize; - let min_target = if target > 4 { target * 3 / 4 } else { target }; - // Only full-bin pages hold candidate segments (mirrors C's heuristic of - // not maintaining a per-thread segment list). - for _ in 0..64 { - if tld.count.get() < min_target { - break; - } - let mut p = heap.pages[crate::constants::BIN_FULL].first.get(); - // SAFETY: queue nodes are live pages of this heap. - while let Some(page) = unsafe { p.as_ref() } { - if page.block_size() <= LARGE_OBJ_SIZE_MAX { - break; - } - p = page.next.get(); - } - // SAFETY: queue nodes are live pages of this heap. - let Some(page) = (unsafe { p.as_ref() }) else { - break; - }; - segment_force_abandon(page.segment()); - } -} - -/// `mi_segment_reclaim_or_alloc` -fn reclaim_or_alloc<'a>( - heap: &Heap, - needed_slices: usize, - block_size: usize, - tld: &SegmentsTld, -) -> Option<&'a Segment> { - debug_assert!(block_size <= LARGE_OBJ_SIZE_MAX); - // Shed excess segments first so other threads can reclaim them. - segments_try_abandon(heap, tld); - let mut reclaimed = false; - let segment = try_reclaim(heap, needed_slices, block_size, &mut reclaimed, tld); - if reclaimed { - // The right page is already in the heap's queue; report "no segment" - // so the caller retries through the page queues. - return None; - } - if let Some(segment) = segment { - return Some(segment); - } - segment_alloc(0, 0, heap.arena_id.get(), tld).map(|(s, _)| s) -} - -/// `mi_segments_page_alloc` -fn segments_page_alloc<'a>( - heap: &Heap, - required: usize, - block_size: usize, - tld: &SegmentsTld, -) -> Option<&'a Page> { - let page_size = align_up( - required, - if required > MEDIUM_PAGE_SIZE { - MEDIUM_PAGE_SIZE - } else { - SEGMENT_SLICE_SIZE - }, - ); - let slices_needed = page_size / SEGMENT_SLICE_SIZE; - loop { - if let Some(page) = page_find_and_allocate(slices_needed, heap.arena_id.get(), tld) { - page.segment().try_purge(false); - return Some(page); - } - reclaim_or_alloc(heap, slices_needed, block_size, tld)?; - } -} - -/// `mi_segment_huge_page_alloc` -fn huge_page_alloc<'a>( - size: usize, - page_alignment: usize, - req_arena: crate::arena::ArenaId, - tld: &SegmentsTld, -) -> Option<&'a Page> { - let (segment, page) = segment_alloc(size, page_alignment, req_arena, tld)?; - let page = page?; - debug_assert!(segment.used.get() == 1 && page.block_size() >= size); - let (start, psize) = segment.page_start(page); - page.set_block_size(psize); - debug_assert!(page.is_huge.get()); - - // Decommit the unused prefix of very large alignments. - if page_alignment > 0 && segment.allow_decommit.get() { - let aligned = align_up(start.addr(), page_alignment); - debug_assert!(psize - (aligned - start.addr()) >= size); - // SAFETY: range is inside the page, past the block header word. - unsafe { - let decommit_start = start.add(size_of::()); // keep the free-list word - let len = aligned - decommit_start.addr(); - if len > 0 { - os::reset(decommit_start, len); - } - } - } - Some(page) -} - -/// `_mi_segment_huge_page_reset`: a huge block was freed on another thread; -/// release its physical memory while the owner still holds it. -pub fn huge_page_reset(segment: &Segment, page: &Page, block: *mut crate::page::Block) { - debug_assert!(segment.kind.get() == SegmentKind::Huge); - debug_assert!(page.used.get() == 1 && page.free.get().is_null()); - if segment.allow_decommit.get() { - let csize = page.block_size() - size_of::(); - // SAFETY: the huge block spans the page; skip the first word. - unsafe { - os::reset(block.cast::().add(size_of::()), csize); - } - } -} - -/// `_mi_segment_page_alloc`: the entry point from `page.c`. -pub fn segment_page_alloc<'a>( - heap: &Heap, - block_size: usize, - page_alignment: usize, - tld: &SegmentsTld, -) -> Option<&'a Page> { - let page = if page_alignment > BLOCK_ALIGNMENT_MAX { - debug_assert!(is_power_of_two(page_alignment)); - let page_alignment = page_alignment.max(SEGMENT_SIZE); - huge_page_alloc(block_size, page_alignment, heap.arena_id.get(), tld)? - } else if block_size <= SMALL_OBJ_SIZE_MAX { - segments_page_alloc(heap, block_size, block_size, tld)? - } else if block_size <= MEDIUM_OBJ_SIZE_MAX { - segments_page_alloc(heap, MEDIUM_PAGE_SIZE, block_size, tld)? - } else if block_size <= LARGE_OBJ_SIZE_MAX { - segments_page_alloc(heap, block_size, block_size, tld)? - } else { - huge_page_alloc(block_size, page_alignment, heap.arena_id.get(), tld)? - }; - Some(page) -} diff --git a/vendor/rimalloc/src/stats.rs b/vendor/rimalloc/src/stats.rs deleted file mode 100644 index 11664758..00000000 --- a/vendor/rimalloc/src/stats.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Statistics (port of `stats.c`, condensed). Segment/page-level events are -//! always counted (they are rare); per-allocation counting is gated behind -//! the `stats` cargo feature, mirroring C's `MI_STAT` levels. - -use core::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; - -/// `mi_stat_count_t`: current/peak/total. -#[derive(Default)] -pub struct StatCount { - pub current: AtomicI64, - pub peak: AtomicI64, - pub total: AtomicI64, -} - -impl StatCount { - pub const fn new() -> StatCount { - StatCount { - current: AtomicI64::new(0), - peak: AtomicI64::new(0), - total: AtomicI64::new(0), - } - } - pub fn increase(&self, amount: usize) { - let cur = self.current.fetch_add(amount as i64, Ordering::Relaxed) + amount as i64; - self.peak.fetch_max(cur, Ordering::Relaxed); - self.total.fetch_add(amount as i64, Ordering::Relaxed); - } - pub fn decrease(&self, amount: usize) { - self.current.fetch_sub(amount as i64, Ordering::Relaxed); - } - fn get(&self) -> (i64, i64, i64) { - ( - self.current.load(Ordering::Relaxed), - self.peak.load(Ordering::Relaxed), - self.total.load(Ordering::Relaxed), - ) - } -} - -/// Process-wide statistics (`_mi_stats_main`). -pub struct Stats { - pub segments: StatCount, - pub pages: StatCount, - pub committed: StatCount, - pub reserved: StatCount, - pub threads: StatCount, - pub malloc_normal: StatCount, - pub malloc_huge: StatCount, - #[allow(dead_code)] // read by stats-feature builds / external consumers - pub malloc_normal_count: AtomicUsize, - #[allow(dead_code)] - pub malloc_huge_count: AtomicUsize, - pub pages_extended: AtomicUsize, - pub pages_retire: AtomicUsize, - pub segments_abandoned: AtomicUsize, - pub segments_reclaimed: AtomicUsize, -} - -pub static STATS: Stats = Stats { - segments: StatCount::new(), - pages: StatCount::new(), - committed: StatCount::new(), - reserved: StatCount::new(), - threads: StatCount::new(), - malloc_normal: StatCount::new(), - malloc_huge: StatCount::new(), - malloc_normal_count: AtomicUsize::new(0), - malloc_huge_count: AtomicUsize::new(0), - pages_extended: AtomicUsize::new(0), - pages_retire: AtomicUsize::new(0), - segments_abandoned: AtomicUsize::new(0), - segments_reclaimed: AtomicUsize::new(0), -}; - -/// Per-allocation accounting; compiled out unless the `stats` feature is on -/// (like release-mode C with `MI_STAT=0`). -#[inline(always)] -pub fn stat_malloc(size: usize, huge: bool) { - #[cfg(feature = "stats")] - { - if huge { - STATS.malloc_huge.increase(size); - STATS.malloc_huge_count.fetch_add(1, Ordering::Relaxed); - } else { - STATS.malloc_normal.increase(size); - STATS.malloc_normal_count.fetch_add(1, Ordering::Relaxed); - } - } - #[cfg(not(feature = "stats"))] - { - let _ = (size, huge); - } -} - -fn fmt_bytes(n: i64) -> String { - let n = n.max(0) as f64; - if n >= 1e9 { - format!("{:.1} GiB", n / (1u64 << 30) as f64) - } else if n >= 1e6 { - format!("{:.1} MiB", n / (1u64 << 20) as f64) - } else if n >= 1e3 { - format!("{:.1} KiB", n / 1024.0) - } else { - format!("{n} B") - } -} - -/// `mi_stats_print`: write a summary to stderr (or any writer). -pub fn print(out: &mut dyn std::io::Write) -> std::io::Result<()> { - let rows: &[(&str, &StatCount, bool)] = &[ - ("reserved", &STATS.reserved, true), - ("committed", &STATS.committed, true), - ("segments", &STATS.segments, false), - ("pages", &STATS.pages, false), - ("threads", &STATS.threads, false), - ("malloc normal", &STATS.malloc_normal, true), - ("malloc huge", &STATS.malloc_huge, true), - ]; - writeln!( - out, - "{:<14} {:>12} {:>12} {:>12}", - "heap stats:", "current", "peak", "total" - )?; - for (name, stat, bytes) in rows { - let (cur, peak, total) = stat.get(); - if *bytes { - writeln!( - out, - "{:<14} {:>12} {:>12} {:>12}", - name, - fmt_bytes(cur), - fmt_bytes(peak), - fmt_bytes(total) - )?; - } else { - writeln!(out, "{name:<14} {cur:>12} {peak:>12} {total:>12}")?; - } - } - writeln!( - out, - "{:<14} extended: {}, retired: {}, abandoned: {}, reclaimed: {}", - "pages ops:", - STATS.pages_extended.load(Ordering::Relaxed), - STATS.pages_retire.load(Ordering::Relaxed), - STATS.segments_abandoned.load(Ordering::Relaxed), - STATS.segments_reclaimed.load(Ordering::Relaxed), - )?; - Ok(()) -} diff --git a/vendor/rimalloc/src/sync.rs b/vendor/rimalloc/src/sync.rs deleted file mode 100644 index 0a48e607..00000000 --- a/vendor/rimalloc/src/sync.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Tiny spinlock used by the global caches and lists. Contention is rare -//! (segment-level events only) and critical sections are a few pointer -//! writes, so spinning beats an OS mutex here. - -use core::hint::spin_loop; -use core::sync::atomic::{AtomicBool, Ordering}; - -#[inline] -pub(crate) fn spin_acquire(lock: &AtomicBool) { - while lock - .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_err() - { - spin_loop(); - } -} - -#[inline] -pub(crate) fn spin_release(lock: &AtomicBool) { - lock.store(false, Ordering::Release); -} - -/// Run `f` under `lock`. -#[inline] -pub(crate) fn spin_locked(lock: &AtomicBool, f: impl FnOnce() -> R) -> R { - spin_acquire(lock); - let r = f(); - spin_release(lock); - r -} diff --git a/vendor/rimalloc/src/visit.rs b/vendor/rimalloc/src/visit.rs deleted file mode 100644 index 48042b2e..00000000 --- a/vendor/rimalloc/src/visit.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Heap walking (port of `heap.c`'s `mi_heap_visit_blocks`): iterate every -//! page area of a heap and optionally every live block inside it. Live -//! blocks are found by building a bitmap of the free list — `local_free` -//! and `thread_free` are collected first so `used` is exact. - -use crate::constants::*; -use crate::heap::Heap; -use crate::page::Page; -use crate::page_ops::page_free_collect; - -/// `mi_heap_area_t`: a snapshot of one page's block area. -#[derive(Clone, Copy, Debug)] -pub struct HeapArea { - pub blocks: *mut u8, // start of the area - pub reserved: usize, // bytes reserved - pub committed: usize, // bytes committed - pub used: usize, // blocks in use - pub block_size: usize, // usable block size - pub full_block_size: usize, // size including internal padding - pub heap_tag: u8, -} - -impl HeapArea { - fn init(page: &Page) -> HeapArea { - let bsize = page.block_size(); - HeapArea { - blocks: page.start(), - reserved: page.reserved.get() as usize * bsize, - committed: page.capacity.get() as usize * bsize, - used: page.used.get() as usize, - block_size: page.usable_block_size(), - full_block_size: bsize, - heap_tag: page.heap_tag.get(), - } - } -} - -const MAX_BLOCKS: usize = SMALL_PAGE_SIZE / PTR_SIZE; // 8192 -const MAP_WORDS: usize = MAX_BLOCKS / usize::BITS as usize; - -/// `_mi_heap_area_visit_blocks`: call `visitor(block, usable_size)` for -/// every live block of `page`; stops early on `false`. -fn area_visit_blocks(page: &Page, visitor: &mut impl FnMut(*mut u8, usize) -> bool) -> bool { - if page.used.get() == 0 { - return true; - } - let pstart = page.start(); - let bsize = page.block_size(); - let ubsize = page.usable_block_size(); - - // optimize: page with one block - if page.capacity.get() == 1 { - return visitor(pstart, ubsize); - } - - // optimize: full page - if page.used.get() == page.capacity.get() { - let mut block = pstart; - for _ in 0..page.capacity.get() { - if !visitor(block, ubsize) { - return false; - } - // SAFETY: blocks stay within the page area. - block = unsafe { block.add(bsize) }; - } - return true; - } - - // bitmap of free blocks - let capacity = page.capacity.get() as usize; - debug_assert!(capacity <= MAX_BLOCKS); - let mut free_map = [0usize; MAP_WORDS]; - let bmapsize = capacity.div_ceil(usize::BITS as usize); - if !capacity.is_multiple_of(usize::BITS as usize) { - // mark left-over bits at the end as free - free_map[bmapsize - 1] = usize::MAX << (capacity % usize::BITS as usize); - } - let mut p = page.free.get(); - while !p.is_null() { - let offset = p.addr() - pstart.addr(); - debug_assert!(offset.is_multiple_of(bsize)); - let blockidx = offset / bsize; - free_map[blockidx / usize::BITS as usize] |= 1 << (blockidx % usize::BITS as usize); - // SAFETY: free-list nodes are live blocks in this page. - p = unsafe { page.block_next(p) }; - } - - // walk all blocks, skipping the free ones - let mut block = pstart; - for &word in &free_map[..bmapsize] { - if word == 0 { - // every block in this word is in use - for _ in 0..usize::BITS { - if !visitor(block, ubsize) { - return false; - } - // SAFETY: in-bounds while bits remain. - block = unsafe { block.add(bsize) }; - } - } else { - let mut m = !word; - while m != 0 { - let bitidx = m.trailing_zeros() as usize; - // SAFETY: bit indices map within the page area. - if !visitor(unsafe { block.add(bitidx * bsize) }, ubsize) { - return false; - } - m &= m - 1; - } - // SAFETY: word-stride within (or one past) the page area. - block = unsafe { block.add(bsize * usize::BITS as usize) }; - } - } - true -} - -/// `mi_heap_visit_blocks`: visit every area of `heap`, and every live block -/// within each area when `visit_blocks` is true. The visitor receives the -/// area and, for block visits, `Some((block, usable_size))`; returning -/// `false` stops the walk. Returns `false` if the walk was stopped. -pub fn heap_visit_blocks( - heap: &Heap, - visit_blocks: bool, - visitor: &mut impl FnMut(&HeapArea, Option<(*mut u8, usize)>) -> bool, -) -> bool { - crate::heap_ops::heap_visit_pages(heap, |_, _, page| { - // collect so the used count is exact - page_free_collect(page, true); - let area = HeapArea::init(page); - if !visitor(&area, None) { - return false; - } - if visit_blocks { - area_visit_blocks(page, &mut |block, size| visitor(&area, Some((block, size)))) - } else { - true - } - }) -} diff --git a/vendor/rimalloc/tests/api.rs b/vendor/rimalloc/tests/api.rs deleted file mode 100644 index b04005d3..00000000 --- a/vendor/rimalloc/tests/api.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Faithful port of mimalloc `test/test-api.c` (C++-only STL-allocator and -//! `realpath` cases are exercised through Rust equivalents or skipped). - -use rimalloc as mi; - -const SMALL_SIZE_MAX: usize = 128 * size_of::(); -const MAX_ALIGN_SIZE: usize = 16; -const BLOCK_ALIGNMENT_MAX: usize = 32 * 1024 * 1024 / 2; // MI_SEGMENT_SIZE/2 -const MIB: usize = 1024 * 1024; - -fn mem_is_zero(p: *const u8, size: usize) -> bool { - if p.is_null() { - return false; - } - (0..size).all(|i| unsafe { *p.add(i) } == 0) -} - -#[test] -fn malloc_aligned9a() { - // test large alignments - let p = mi::zalloc_aligned(1024 * 1024, 2); - mi::free(p); - let p = mi::zalloc_aligned(1024 * 1024, 2); - mi::free(p); -} - -#[test] -fn malloc_zero() { - let p = mi::malloc(0); - assert!(!p.is_null()); - mi::free(p); -} - -#[test] -fn malloc_nomem1() { - assert!(mi::malloc(isize::MAX as usize + 1).is_null()); -} - -#[test] -fn malloc_null() { - mi::free(std::ptr::null_mut()); -} - -#[test] -fn calloc_overflow() { - assert!(mi::calloc(&raw const COOKIE as usize, usize::MAX / 1000).is_null()); -} -static COOKIE: u8 = 0; - -#[test] -fn calloc0() { - let p = mi::calloc(0, 1000); - assert!(mi::usable_size(p) <= 16); - mi::free(p); -} - -#[test] -fn malloc_large() { - // see PR #544 - let p = mi::malloc(67108872); - mi::free(p); -} - -#[test] -fn posix_memalign1() { - let mut p: *mut u8 = std::ptr::null_mut(); - let err = mi::posix_memalign(&mut p, size_of::(), 32); - assert!(err == 0 && p.addr() % size_of::() == 0); - mi::free(p); -} - -#[test] -fn posix_memalign_no_align() { - let mut p: *mut u8 = std::ptr::dangling_mut(); - let err = mi::posix_memalign(&mut p, 3, 32); - assert!(err == mi::EINVAL && p == std::ptr::dangling_mut()); -} - -#[test] -fn posix_memalign_zero() { - let mut p: *mut u8 = std::ptr::null_mut(); - let err = mi::posix_memalign(&mut p, size_of::(), 0); - mi::free(p); - assert_eq!(err, 0); -} - -#[test] -fn posix_memalign_nopow2() { - let mut p: *mut u8 = std::ptr::dangling_mut(); - let err = mi::posix_memalign(&mut p, 3 * size_of::(), 32); - assert!(err == mi::EINVAL && p == std::ptr::dangling_mut()); -} - -#[test] -fn posix_memalign_nomem() { - let mut p: *mut u8 = std::ptr::dangling_mut(); - let err = mi::posix_memalign(&mut p, size_of::(), usize::MAX); - assert!(err == mi::ENOMEM && p == std::ptr::dangling_mut()); -} - -#[test] -fn malloc_aligned1() { - let p = mi::malloc_aligned(32, 32); - assert!(!p.is_null() && p.addr() % 32 == 0); - mi::free(p); -} - -#[test] -fn malloc_aligned2() { - let p = mi::malloc_aligned(48, 32); - assert!(!p.is_null() && p.addr() % 32 == 0); - mi::free(p); -} - -#[test] -fn malloc_aligned3() { - let p1 = mi::malloc_aligned(48, 32); - assert!(!p1.is_null() && p1.addr() % 32 == 0); - let p2 = mi::malloc_aligned(48, 32); - assert!(!p2.is_null() && p2.addr() % 32 == 0); - mi::free(p2); - mi::free(p1); -} - -#[test] -fn malloc_aligned4() { - for _ in 0..8 { - let p = mi::malloc_aligned(8, 16); - assert!(!p.is_null() && p.addr() % 16 == 0); - mi::free(p); - } -} - -#[test] -fn malloc_aligned5() { - let p = mi::malloc_aligned(4097, 4096); - let usable = mi::usable_size(p); - assert!(usable >= 4097 && usable < 16000, "usable: {usable}"); - mi::free(p); -} - -#[test] -fn malloc_aligned7() { - let p = mi::malloc_aligned(1024, BLOCK_ALIGNMENT_MAX); - assert_eq!(p.addr() % BLOCK_ALIGNMENT_MAX, 0); - mi::free(p); -} - -#[test] -fn malloc_aligned8() { - for i in 0..5 { - let n = 1usize << i; - let p = mi::malloc_aligned(1024, n * BLOCK_ALIGNMENT_MAX); - assert_eq!(p.addr() % (n * BLOCK_ALIGNMENT_MAX), 0, "n={n}"); - mi::free(p); - } -} - -#[test] -fn malloc_aligned9() { - // test large alignments - let sizes: [usize; 8] = [ - 8, - 512, - 1024 * 1024, - BLOCK_ALIGNMENT_MAX, - BLOCK_ALIGNMENT_MAX + 1, - 2 * BLOCK_ALIGNMENT_MAX, - 8 * BLOCK_ALIGNMENT_MAX, - 0, - ]; - let step = if cfg!(miri) { 5 } else { 1 }; - for i in (0..28).step_by(step) { - let align = 1usize << i; - let mut p = [std::ptr::null_mut(); 8]; - for (j, &size) in sizes.iter().enumerate() { - p[j] = mi::zalloc_aligned(size, align); - assert_eq!(p[j].addr() % align, 0, "align={align} size={size}"); - } - for q in p { - mi::free(q); - } - } -} - -#[test] -fn malloc_aligned10() { - let mut p = [std::ptr::null_mut(); 11]; - let mut align = 1; - for j in 0..=10 { - p[j] = mi::malloc_aligned(43 + align, align); - assert_eq!(p[j].addr() % align, 0, "align={align}"); - align *= 2; - } - for q in p.into_iter().rev() { - mi::free(q); - } -} - -#[test] -fn malloc_aligned11() { - let heap = mi::heap_new().unwrap(); - let p = mi::heap_malloc_aligned(heap, 33554426, 8); - assert!(unsafe { mi::heap_contains_block(heap, p) }); - mi::heap_destroy(heap); -} - -#[test] -fn malloc_aligned12() { - let p = mi::malloc_aligned(0x100, 0x100); - assert_eq!(p.addr() % 0x100, 0); // #602 - mi::free(p); -} - -#[test] -fn malloc_aligned13() { - // Under Miri sample the size domain (the interpreter is ~1000x slower); - // natively the full domain is covered. - let step = if cfg!(miri) { 41 } else { 1 }; - let mut size = 1usize; - while size <= SMALL_SIZE_MAX * 2 { - let mut align = 1usize; - while align <= size { - let mut p = [std::ptr::null_mut(); 10]; - for slot in &mut p { - *slot = mi::malloc_aligned(size, align); - assert!( - !slot.is_null() && slot.addr() % align == 0, - "size={size} align={align}" - ); - } - for q in p { - mi::free(q); - } - align *= 2; - } - size += step; - } -} - -#[test] -fn malloc_aligned_at1() { - let p = mi::malloc_aligned_at(48, 32, 0); - assert!(!p.is_null() && p.addr() % 32 == 0); - mi::free(p); -} - -#[test] -fn malloc_aligned_at2() { - let p = mi::malloc_aligned_at(50, 32, 8); - assert!(!p.is_null() && (p.addr() + 8) % 32 == 0); - mi::free(p); -} - -#[test] -fn memalign1() { - for _ in 0..8 { - let p = mi::memalign(16, 8); - assert!(!p.is_null() && p.addr() % 16 == 0); - mi::free(p); - } -} - -#[test] -fn zalloc_aligned_small1() { - let zalloc_size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc_aligned(zalloc_size, MAX_ALIGN_SIZE * 2); - assert!(mem_is_zero(p, zalloc_size)); - mi::free(p); -} - -#[test] -fn rezalloc_aligned_small1() { - let mut zalloc_size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc_aligned(zalloc_size, MAX_ALIGN_SIZE * 2); - assert!(mem_is_zero(p, zalloc_size)); - zalloc_size *= 3; - let p = mi::rezalloc_aligned(p, zalloc_size, MAX_ALIGN_SIZE * 2); - assert!(mem_is_zero(p, zalloc_size)); - mi::free(p); -} - -#[test] -fn realloc_null() { - let p = mi::realloc(std::ptr::null_mut(), 4); - assert!(!p.is_null()); - mi::free(p); -} - -#[test] -fn realloc_null_sizezero() { - // "If ptr is NULL, the behavior is the same as calling malloc(new_size)." - let p = mi::realloc(std::ptr::null_mut(), 0); - assert!(!p.is_null()); - mi::free(p); -} - -#[test] -fn realloc_sizezero() { - let p = mi::malloc(4); - let q = mi::realloc(p, 0); - assert!(!q.is_null()); - mi::free(q); -} - -#[test] -fn reallocarray_null_sizezero() { - let p = mi::reallocarray(std::ptr::null_mut(), 0, 16); // issue #574 - assert!(!p.is_null()); - mi::free(p); -} - -#[test] -fn umalloc1() { - let limit = if cfg!(miri) { MIB } else { 32 * MIB }; - let mut size = 1usize; - while size <= limit { - let mut bsize = 0; - let p = mi::umalloc(size, &mut bsize); - assert!(bsize >= size); - let (mut pre, mut post) = (0, 0); - let p = mi::urealloc(p, size + 1024, &mut pre, &mut post); - assert_eq!(pre, bsize); - assert!(post >= size + 1024); - let mut fsize = 0; - mi::ufree(p, &mut fsize); - assert_eq!(fsize, post); - size *= 2; - } -} - -#[test] -fn heap_destroy_test() { - // test_heap1 - let heap = mi::heap_new().unwrap(); - let p1 = mi::heap_malloc(heap, size_of::()).cast::(); - let p2 = mi::heap_malloc(heap, size_of::()).cast::(); - unsafe { - *p1 = 43; - *p2 = 43; - } - mi::heap_destroy(heap); -} - -#[test] -fn heap_delete_test() { - // test_heap2 - let heap = mi::heap_new().unwrap(); - let p1 = mi::heap_malloc(heap, size_of::()).cast::(); - let p2 = mi::heap_malloc(heap, size_of::()).cast::(); - mi::heap_delete(heap); - unsafe { *p1 = 42 }; - mi::free(p1.cast()); - mi::free(p2.cast()); -} - -#[test] -fn rust_allocator_vec() { - // stand-in for the C++ STL allocator tests - let mut sizes = vec![]; - for i in 0..64usize { - let p = mi::malloc(i * 17 + 1); - sizes.push((p, i * 17 + 1)); - } - for (p, _) in sizes { - mi::free(p); - } -} - -#[test] -fn heap_visit_blocks_walk() { - // mirror of test-stress's HEAP_WALK: sum visited block sizes and count - // blocks; all live allocations must be visited exactly once. - let heap = mi::heap_new().unwrap(); - let sizes = [1usize, 8, 16, 100, 700, 8000, 70_000, 200_000]; - let ptrs: Vec<*mut u8> = sizes.iter().map(|&s| mi::heap_malloc(heap, s)).collect(); - // free one so a partially-used page is walked via the bitmap path - mi::free(ptrs[1]); - - let mut count = 0usize; - let mut total = 0usize; - let complete = mi::heap_visit_blocks(heap, true, &mut |area, block| { - assert!(area.committed <= area.reserved); - if let Some((p, size)) = block { - assert!(!p.is_null() && size > 0); - count += 1; - total += size; - } - true - }); - assert!(complete); - assert_eq!(count, sizes.len() - 1); - assert!(total >= sizes.iter().sum::() - sizes[1]); - - // early stop works - let mut seen = 0; - let complete = mi::heap_visit_blocks(heap, true, &mut |_, block| { - if block.is_some() { - seen += 1; - return seen < 2; - } - true - }); - assert!(!complete && seen == 2); - - for (i, p) in ptrs.iter().enumerate() { - if i != 1 { - mi::free(*p); - } - } - mi::heap_destroy(heap); -} diff --git a/vendor/rimalloc/tests/api_alignment.rs b/vendor/rimalloc/tests/api_alignment.rs deleted file mode 100644 index 9b8ae5a7..00000000 --- a/vendor/rimalloc/tests/api_alignment.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Regression tests for GlobalAlloc alignment and realloc edge cases found -//! by adversarial differential testing against C mimalloc. - -use std::alloc::{GlobalAlloc, Layout}; - -/// The 8-byte size class is only 8-aligned, so a `{size <= 8, align: 16}` -/// layout must route through the aligned allocator (C's -/// `mi_malloc_is_naturally_aligned`). -#[test] -fn global_alloc_honors_align16_for_tiny_sizes() { - let a = rimalloc::Rimalloc; - for size in 1..=16usize { - for &align in &[8usize, 16, 32, 64] { - let layout = Layout::from_size_align(size, align).unwrap(); - // Miri interprets every allocation; a few iterations exercise the - // same routing the native run hammers 256x. - let iters = if cfg!(miri) { 4 } else { 256 }; - for _ in 0..iters { - let p = unsafe { a.alloc(layout) }; - assert!(!p.is_null()); - assert_eq!(p.addr() % align, 0, "size {size} align {align}: {:p}", p); - let z = unsafe { a.alloc_zeroed(layout) }; - assert_eq!(z.addr() % align, 0); - assert_eq!(unsafe { *z }, 0); - unsafe { - a.dealloc(p, layout); - a.dealloc(z, layout); - } - } - } - } -} - -/// `realloc` preserving a large alignment must still honor it, and a big -/// shrink must release the oversized block rather than retain it forever. -#[test] -fn realloc_aligned_shrinks_and_keeps_alignment() { - let p = rimalloc::malloc_aligned(8 << 20, 256); - assert_eq!(p.addr() % 256, 0); - let q = rimalloc::realloc_aligned(p, 64, 256); - assert_eq!(q.addr() % 256, 0); - assert!( - rimalloc::usable_size(q) < 4096, - "aligned shrink retained the oversized block: {}", - rimalloc::usable_size(q) - ); - rimalloc::free(q); -} - -/// `realloc(p, 0)` returns a non-null minimal block whose first byte is -/// zeroed (C issue #725), and frees the original. -#[test] -fn realloc_to_zero_is_zeroed() { - let p = rimalloc::malloc(128); - unsafe { p.write_bytes(0xAB, 128) }; - let q = rimalloc::realloc(p, 0); - assert!(!q.is_null()); - assert_eq!(unsafe { *q }, 0); - rimalloc::free(q); -} diff --git a/vendor/rimalloc/tests/arena_purge.rs b/vendor/rimalloc/tests/arena_purge.rs deleted file mode 100644 index 44f4019b..00000000 --- a/vendor/rimalloc/tests/arena_purge.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! A freed span inside a *live* arena-backed segment must be returned to the -//! OS in-life (C purges every segment's freed spans via mi_segment_span_free, -//! regardless of backing — only whole-segment teardown is left to the arena). -//! Skipping this for arena-backed segments leaks resident memory until the -//! whole segment is freed (a ~2x RSS regression in decommit mode). -//! -//! Deterministic signal: in decommit mode an in-life purge calls os::decommit, -//! which remaps PROT_NONE via mmap (syscall counter index 0). A pin keeps the -//! segment alive so the free cannot trigger whole-segment teardown instead. - -use rimalloc::options::{self, Option}; - -#[test] -fn arena_segment_frees_span_in_life() { - // Decommit mode, immediate purge (no delay), so the free itself purges. - options::set(Option::PurgeDecommits, 1); - options::set(Option::PurgeDelay, 0); - - // Pin a (32MiB, arena-backed) segment so it stays live across the free. - let pin = rimalloc::malloc(1024); - assert!(!pin.is_null()); - - // A large span (< LARGE_OBJ_SIZE_MAX = 16MiB) lands as a large page in the - // same segment. Touch it so the pages are resident, then free it. - let span = 12 << 20; - let big = rimalloc::malloc(span); - assert!(!big.is_null()); - unsafe { big.write_bytes(0xA5, span) }; - - let decommits_before = rimalloc::os_syscall_counts()[0]; - rimalloc::free(big); - let decommits_after = rimalloc::os_syscall_counts()[0]; - - assert!( - decommits_after > decommits_before, - "freed span of a live arena-backed segment was not decommitted in-life \ - (RSS regression): decommit syscalls {decommits_before} -> {decommits_after}" - ); - - rimalloc::free(pin); -} diff --git a/vendor/rimalloc/tests/committed_stat.rs b/vendor/rimalloc/tests/committed_stat.rs deleted file mode 100644 index 59d8c442..00000000 --- a/vendor/rimalloc/tests/committed_stat.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! The `committed` stat tracks committed bytes at whole-segment -//! granularity (see `SegmentsTld::track_size`): it must rise when segments -//! are created, fall when they are released, and never go negative — under -//! both reset-mode and decommit-mode purges. -//! -//! The whole-segment counter is balanced *by construction*: every increase -//! (segment enters) is paired with an equal decrease (segment leaves) at the -//! same granularity, so a churn cycle can never drive it negative and it -//! returns to its pre-churn baseline once everything is collected — in either -//! purge mode. (A globally *exact*, sub-segment counter is deliberately not -//! attempted; see the `track_size` doc comment for why it stays coarse.) -#![cfg(feature = "stats")] - -use rimalloc::options::{self, Option}; - -#[test] -fn committed_rises_and_falls_without_underflow() { - let base = rimalloc::stats_committed_current(); - assert!(base >= 0); - - let p = rimalloc::malloc(8 * 1024); - assert!(!p.is_null()); - unsafe { p.write_bytes(7, 8 * 1024) }; - let after_alloc = rimalloc::stats_committed_current(); - assert!(after_alloc > base, "segment commit not counted"); - - rimalloc::free(p); - rimalloc::collect(true); - let after_free = rimalloc::stats_committed_current(); - assert!( - after_free >= 0, - "committed stat went negative: {after_free}" - ); - assert!( - after_free <= after_alloc, - "free/purge did not reduce committed: {after_alloc} -> {after_free}" - ); - - // Aligned (trim-path) mapping and a huge allocation must also balance. - let a = rimalloc::malloc_aligned(1 << 20, 1 << 21); - assert!(!a.is_null()); - rimalloc::free(a); - let h = rimalloc::malloc(40 << 20); - assert!(!h.is_null()); - rimalloc::free(h); - rimalloc::collect(true); - assert!(rimalloc::stats_committed_current() >= 0); -} - -/// Drive a churn cycle that forces many segments (and arena blocks) through -/// alloc/free/purge, in *both* purge modes, asserting the committed counter -/// never underflows mid-churn and lands back at a bounded baseline after a -/// full collect. This is the exact OS<->arena cross-layer purge path the -/// routing change touches; under the old segment-level decommit of arena -/// memory this churn could drive committed negative. -#[test] -fn committed_never_underflows_across_churn_in_both_modes() { - // 0 = reset/madvise mode, 1 = decommit mode. - for &decommit in &[0_i64, 1_i64] { - options::set(Option::PurgeDecommits, decommit); - // Short delays so purges actually fire during the run. - options::set(Option::PurgeDelay, 0); - - let baseline = rimalloc::stats_committed_current(); - assert!( - baseline >= 0, - "committed negative at baseline (mode decommit={decommit}): {baseline}" - ); - - // Several rounds of churn: enough live segments to span multiple - // arena blocks, freed and re-collected each round. - for round in 0..8 { - let mut live = Vec::new(); - // ~24 MiB of 256 KiB blocks => multiple segments / arena blocks. - for i in 0..96 { - let p = rimalloc::malloc(256 * 1024); - assert!(!p.is_null()); - // Touch the first and last page so the range is really faulted. - unsafe { - p.write_bytes((i & 0xff) as u8, 64); - p.add(256 * 1024 - 64).write_bytes(0xab, 64); - } - live.push(p); - let cur = rimalloc::stats_committed_current(); - assert!( - cur >= 0, - "committed negative during alloc (mode={decommit}, round={round}, i={i}): {cur}" - ); - } - for p in live.drain(..) { - rimalloc::free(p); - let cur = rimalloc::stats_committed_current(); - assert!( - cur >= 0, - "committed negative during free (mode={decommit}, round={round}): {cur}" - ); - } - rimalloc::collect(true); - let cur = rimalloc::stats_committed_current(); - assert!( - cur >= 0, - "committed negative after collect (mode={decommit}, round={round}): {cur}" - ); - } - - rimalloc::collect(true); - let after = rimalloc::stats_committed_current(); - assert!( - after >= 0, - "committed negative after churn (mode decommit={decommit}): {after}" - ); - // The coarse counter is balanced by construction: after a full collect - // it must not have grown unboundedly relative to the round's working - // set (a leak would show here). Allow some cached segments to remain. - assert!( - after <= baseline + (64 << 20), - "committed leaked across churn (mode decommit={decommit}): \ - baseline={baseline} after={after}" - ); - } - // Restore defaults for any later test in this binary. - options::set(Option::PurgeDecommits, 1); - options::set(Option::PurgeDelay, 10); -} diff --git a/vendor/rimalloc/tests/coverage_gaps.rs b/vendor/rimalloc/tests/coverage_gaps.rs deleted file mode 100644 index 6d1793c2..00000000 --- a/vendor/rimalloc/tests/coverage_gaps.rs +++ /dev/null @@ -1,680 +0,0 @@ -//! Targeted tests for the least-covered regions found by cargo-llvm-cov: -//! API wrapper variants and their overflow/OOM arms, huge-alignment -//! branches, `heap_absorb`/`heap_destroy` arms, abandoned-segment -//! reclaim-on-free, the segment cache, arena purging, heap visiting, and -//! the stats/options surfaces. - -#![cfg(not(miri))] // exercised natively; the slow paths here add nothing under Miri - -use rimalloc as mi; - -const MIB: usize = 1024 * 1024; -const SMALL_SIZE_MAX: usize = 128 * size_of::(); -const BLOCK_ALIGNMENT_MAX: usize = 16 * MIB; // MI_SEGMENT_SIZE / 2 -const MAX_ALLOC_SIZE: usize = isize::MAX as usize; - -// --------------------------------------------------------------------------- -// API wrapper variants and overflow arms -// --------------------------------------------------------------------------- - -#[test] -fn small_variants() { - let p = mi::malloc_small(64); - assert!(!p.is_null()); - mi::free(p); - let p = mi::zalloc_small(SMALL_SIZE_MAX); - assert!(!p.is_null()); - assert!((0..SMALL_SIZE_MAX).all(|i| unsafe { *p.add(i) } == 0)); - mi::free(p); -} - -#[test] -fn counted_overflow_arms() { - assert!(mi::mallocn(usize::MAX / 8, 16).is_null()); - assert!(mi::reallocn(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); - assert!(mi::recalloc(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); - assert!(mi::calloc_aligned(usize::MAX / 8, 16, 64).is_null()); - assert!(mi::calloc_aligned_at(usize::MAX / 8, 16, 64, 0).is_null()); - assert!(mi::recalloc_aligned(std::ptr::null_mut(), usize::MAX / 8, 16, 64).is_null()); - assert!(mi::reallocarray(std::ptr::null_mut(), usize::MAX / 8, 16).is_null()); - - let p = mi::mallocn(4, 32); - assert!(!p.is_null()); - let p = mi::reallocn(p, 8, 32); - assert!(!p.is_null()); - assert!(mi::usable_size(p) >= 256); - let p = mi::reallocarray(p, 16, 32); - assert!(!p.is_null()); - mi::free(p); -} - -#[test] -fn good_size_clamps() { - assert!(mi::good_size(100) >= 100); - assert!(mi::good_size(100 * MIB) >= 100 * MIB); - assert_eq!(mi::good_size(usize::MAX), usize::MAX); // over MAX_ALLOC_SIZE - // Requests above the C v2.3.2 cap (SEGMENT_SLICE_SIZE * (u32::MAX-1), - // issue #877) are returned unchanged, not page-rounded — pins the cap. - let over_cap = 65536 * (u32::MAX as usize - 1) + 1; - assert_eq!(mi::good_size(over_cap), over_cap); -} - -#[test] -fn reallocf_frees_on_failure() { - let p = mi::malloc(100); - assert!(!p.is_null()); - let q = mi::reallocf(p, 200); - assert!(!q.is_null()); - // failure: oversized request returns null AND releases q - assert!(mi::reallocf(q, MAX_ALLOC_SIZE).is_null()); -} - -#[test] -fn realloc_fits_in_place() { - let p = mi::malloc(100); - let usable = mi::usable_size(p); - // shrink within 50% waste: same pointer, no move - assert_eq!(mi::realloc(p, usable - 8), p); - assert_eq!(mi::rezalloc(p, usable - 8), p); - mi::free(p); -} - -#[test] -fn aligned_at_offset_variants() { - let p = mi::zalloc_aligned_at(300, 512, 16); - assert!(!p.is_null() && (p.addr() + 16).is_multiple_of(512)); - assert!((0..300).all(|i| unsafe { *p.add(i) } == 0)); - mi::free(p); - let p = mi::calloc_aligned_at(10, 30, 512, 16); - assert!(!p.is_null() && (p.addr() + 16).is_multiple_of(512)); - assert!((0..300).all(|i| unsafe { *p.add(i) } == 0)); - mi::free(p); -} - -#[test] -fn expand_in_place_only() { - assert!(mi::expand(std::ptr::null_mut(), 16).is_null()); - let p = mi::malloc(100); - let usable = mi::usable_size(p); - assert_eq!(mi::expand(p, usable), p); - assert!(mi::expand(p, usable + 1).is_null()); - mi::free(p); -} - -#[test] -fn recalloc_zeroes_growth() { - let p = mi::recalloc(std::ptr::null_mut(), 4, 64); - assert!(!p.is_null()); - unsafe { p.write_bytes(0xAA, 256) }; - let p = mi::recalloc(p, 64, 64); - assert!(!p.is_null()); - assert!((256..4096).all(|i| unsafe { *p.add(i) } == 0)); - mi::free(p); -} - -// --------------------------------------------------------------------------- -// POSIX-style entry points -// --------------------------------------------------------------------------- - -#[test] -fn posix_memalign_errors_and_success() { - let mut out: *mut u8 = std::ptr::null_mut(); - assert_eq!(mi::posix_memalign(&mut out, 3, 100), mi::EINVAL); - assert_eq!(mi::posix_memalign(&mut out, 24, 100), mi::EINVAL); - assert_eq!( - mi::posix_memalign(&mut out, 64, MAX_ALLOC_SIZE), - mi::ENOMEM - ); - assert_eq!(mi::posix_memalign(&mut out, 128, 100), 0); - assert!(!out.is_null() && out.addr().is_multiple_of(128)); - mi::free(out); -} - -#[test] -fn valloc_is_page_aligned() { - let p = mi::valloc(100); - assert!(!p.is_null()); - assert!(p.addr() % 4096 == 0); - mi::free(p); -} - -// --------------------------------------------------------------------------- -// Aligned-allocation branches -// --------------------------------------------------------------------------- - -#[test] -fn huge_alignment_with_offset_fails() { - // alignment > BLOCK_ALIGNMENT_MAX cannot use an offset - assert!(mi::malloc_aligned_at(1024, 2 * BLOCK_ALIGNMENT_MAX, 64).is_null()); -} - -#[test] -fn huge_alignment_zeroed() { - let align = 2 * BLOCK_ALIGNMENT_MAX; - let p = mi::zalloc_aligned(3 * MIB, align); - assert!(!p.is_null() && p.addr().is_multiple_of(align)); - assert!( - (0..3 * MIB) - .step_by(4097) - .all(|i| unsafe { *p.add(i) } == 0) - ); - mi::free(p); - - // small size still goes through the dedicated huge-segment path - let p = mi::malloc_aligned(32, align); - assert!(!p.is_null() && p.addr().is_multiple_of(align)); - mi::free(p); -} - -#[test] -fn aligned_invalid_arguments() { - assert!(mi::malloc_aligned(64, 0).is_null()); - assert!(mi::malloc_aligned(64, 24).is_null()); - assert!(mi::malloc_aligned(MAX_ALLOC_SIZE + 1, 64).is_null()); - assert!(mi::realloc_aligned(std::ptr::null_mut(), 64, 24).is_null()); -} - -#[test] -fn realloc_aligned_paths() { - // null -> fresh aligned allocation - let p = mi::realloc_aligned(std::ptr::null_mut(), 100, 256); - assert!(!p.is_null() && p.addr().is_multiple_of(256)); - unsafe { p.write_bytes(0x5A, 100) }; - // shrink within the keep window (>= half the usable size) -> same pointer - // (large-shrink reallocation is covered in tests/api_alignment.rs). - let usable = mi::usable_size(p); - assert_eq!(mi::realloc_aligned(p, usable - usable / 4, 256), p); - // grow -> move, contents preserved - let q = mi::realloc_aligned(p, 64 * 1024, 256); - assert!(!q.is_null() && q.addr().is_multiple_of(256)); - assert!((0..100).all(|i| unsafe { *q.add(i) } == 0x5A)); - mi::free(q); - - // rezalloc_aligned zeroes the growth tail - let p = mi::rezalloc_aligned(std::ptr::null_mut(), 64, 128); - unsafe { p.write_bytes(0x77, 64) }; - let q = mi::rezalloc_aligned(p, 8192, 128); - assert!(!q.is_null()); - assert!((64..8192).all(|i| unsafe { *q.add(i) } == 0)); - mi::free(q); - - // recalloc_aligned counted wrapper - let p = mi::recalloc_aligned(std::ptr::null_mut(), 16, 64, 512); - assert!(!p.is_null() && p.addr().is_multiple_of(512)); - mi::free(p); - - // realloc_aligned_at keeps the offset congruence - let p = mi::malloc_aligned_at(100, 1024, 8); - assert!(!p.is_null() && (p.addr() + 8).is_multiple_of(1024)); - let q = mi::realloc_aligned_at(p, 50_000, 1024, 8); - assert!(!q.is_null() && (q.addr() + 8).is_multiple_of(1024)); - mi::free(q); -} - -// --------------------------------------------------------------------------- -// GlobalAlloc surface -// --------------------------------------------------------------------------- - -#[test] -fn global_alloc_trait_paths() { - use core::alloc::{GlobalAlloc, Layout}; - let a = mi::Rimalloc; - unsafe { - let small = Layout::from_size_align(100, 8).unwrap(); - let p = a.alloc(small); - assert!(!p.is_null()); - let p = a.realloc(p, small, 5000); - assert!(!p.is_null()); - a.dealloc(p, Layout::from_size_align(5000, 8).unwrap()); - - let p = a.alloc_zeroed(small); - assert!((0..100).all(|i| *p.add(i) == 0)); - a.dealloc(p, small); - - let big_align = Layout::from_size_align(100, 4096).unwrap(); - let p = a.alloc(big_align); - assert!(p.addr().is_multiple_of(4096)); - let p = a.realloc(p, big_align, 9000); - assert!(p.addr().is_multiple_of(4096)); - a.dealloc(p, Layout::from_size_align(9000, 4096).unwrap()); - - let p = a.alloc_zeroed(big_align); - assert!(p.addr().is_multiple_of(4096) && (0..100).all(|i| *p.add(i) == 0)); - a.dealloc(p, big_align); - } -} - -// --------------------------------------------------------------------------- -// Heap API: defaults, tags, absorb, destroy -// --------------------------------------------------------------------------- - -#[test] -fn heap_default_backing_and_tags() { - let backing = mi::heap_get_backing(); - let heap = mi::heap_new_ex(7, true, 0).expect("heap"); - assert!(heap.by_tag(7).is_some()); - assert!(backing.by_tag(7).is_some()); // reachable via the thread heap list - assert!(heap.by_tag(99).is_none()); - - let old = mi::heap_set_default(heap); - let p = mi::malloc(100); // served by the new default heap - assert!(unsafe { mi::heap_contains_block(heap, p) }); - assert!(!unsafe { mi::heap_contains_block(backing, p) }); - mi::free(p); - mi::heap_set_default(old); - assert_eq!( - mi::heap_get_default() as *const mi::Heap, - old as *const mi::Heap - ); - mi::heap_delete(heap); -} - -#[test] -fn heap_counted_wrappers() { - let heap = mi::heap_new().expect("heap"); - assert!(mi::heap_calloc(heap, usize::MAX / 8, 16).is_null()); - assert!(mi::heap_mallocn(heap, usize::MAX / 8, 16).is_null()); - let p = mi::heap_calloc(heap, 8, 32); - assert!((0..256).all(|i| unsafe { *p.add(i) } == 0)); - let p = mi::heap_realloc(heap, p, 1024); - assert!(!p.is_null()); - let q = mi::heap_mallocn(heap, 4, 64); - assert!(!q.is_null()); - mi::free(p); - mi::free(q); - mi::heap_delete(heap); -} - -#[test] -fn heap_delete_absorbs_live_pages() { - // Fill pages (including full ones) in a fresh heap, then delete it: - // heap_absorb must move every page to the backing heap, after which the - // blocks are still valid and freeable. 4 KiB blocks keep page capacity - // low (16/page) so several pages sit in the full queue at delete time. - let heap = mi::heap_new().expect("heap"); - let mut ptrs = Vec::new(); - for i in 0..4096 { - let p = mi::heap_malloc(heap, 16 + (i % 7) * 32); - assert!(!p.is_null()); - unsafe { p.write_bytes(0xC3, 16) }; - ptrs.push(p); - } - for _ in 0..256 { - let p = mi::heap_malloc(heap, 4096); - assert!(!p.is_null()); - unsafe { p.write_bytes(0xC3, 16) }; - ptrs.push(p); - } - mi::heap_delete(heap); - for p in ptrs { - assert!((0..16).all(|i| unsafe { *p.add(i) } == 0xC3)); - mi::free(p); - } - mi::collect(false); -} - -#[test] -fn heap_destroy_discards_blocks() { - // heap_destroy frees whole pages without freeing individual blocks. - let heap = mi::heap_new().expect("heap"); - for i in 0..512 { - assert!(!mi::heap_malloc(heap, 8 + (i % 11) * 100).is_null()); - } - assert!(!mi::heap_malloc(heap, 20 * MIB).is_null()); // huge page too - mi::heap_destroy(heap); -} - -#[test] -fn heap_destroy_without_destroy_permission_deletes() { - // A heap created with allow_destroy = false routes heap_destroy through - // heap_delete (it may hold reclaimed pages). - let heap = mi::heap_new_ex(0, false, 0).expect("heap"); - let p = mi::heap_malloc(heap, 100); - assert!(!p.is_null()); - mi::heap_destroy(heap); - mi::free(p); // absorbed by the backing heap, still freeable -} - -#[test] -fn heap_delete_from_middle_of_list() { - // The thread heap list is LIFO; deleting the older heap first walks the - // list past the newer one. - let h1 = mi::heap_new().expect("h1"); - let h2 = mi::heap_new().expect("h2"); - assert!(!mi::heap_malloc(h1, 64).is_null()); - assert!(!mi::heap_malloc(h2, 64).is_null()); - mi::heap_delete(h1); - mi::heap_delete(h2); -} - -// --------------------------------------------------------------------------- -// Ownership introspection -// --------------------------------------------------------------------------- - -#[test] -fn ownership_checks() { - let p = mi::malloc(100); - assert!(unsafe { mi::check_owned(p) }); - assert!(!unsafe { mi::check_owned(std::ptr::null()) }); - assert!(!unsafe { mi::check_owned(p.wrapping_add(1)) }); // unaligned - assert!(mi::is_in_heap_region(p)); - assert!(!mi::is_in_heap_region(std::ptr::null())); - let stack = 0u8; - assert!(!mi::is_in_heap_region(&raw const stack)); - let heap = mi::heap_get_default(); - assert!(unsafe { mi::heap_check_owned(heap, p) }); - assert!(!unsafe { mi::heap_contains_block(heap, std::ptr::null()) }); - mi::free(p); -} - -// --------------------------------------------------------------------------- -// Heap visiting (single-block, full-page, and bitmap paths) -// --------------------------------------------------------------------------- - -#[test] -fn visit_blocks_all_page_shapes() { - let heap = mi::heap_new().expect("heap"); - let mut live = Vec::new(); - // partial small page, dense prefix: blocks 0..256 stay live so whole - // bitmap words are all-in-use, the tail is freed - let mut small = Vec::new(); - for _ in 0..300 { - small.push(mi::heap_malloc(heap, 48)); - } - for (i, &p) in small.iter().enumerate() { - if i >= 256 || i % 65 == 64 { - mi::free(p); - } else { - live.push(p); - } - } - // a fully-used page: 8 KiB blocks fill a 64 KiB small page - for _ in 0..8 { - let p = mi::heap_malloc(heap, 8 * 1024 - 64); - assert!(!p.is_null()); - live.push(p); - } - // a one-block (huge) page - let huge = mi::heap_malloc(heap, 20 * MIB); - assert!(!huge.is_null()); - live.push(huge); - - let mut areas = 0usize; - let mut blocks = 0usize; - let complete = mi::heap_visit_blocks(heap, true, &mut |area, block| { - match block { - None => { - assert!(!area.blocks.is_null() && area.full_block_size >= area.block_size); - areas += 1; - } - Some((p, size)) => { - assert!(size > 0 && !p.is_null()); - blocks += 1; - } - } - true - }); - assert!(complete); - assert!(areas >= 2); - assert_eq!(blocks, live.len()); - - // early stop after the first area - let stopped = mi::heap_visit_blocks(heap, true, &mut |_, _| false); - assert!(!stopped); - // area-only walk - assert!(mi::heap_visit_blocks(heap, false, &mut |_, b| { - assert!(b.is_none()); - true - })); - - // a retired-but-retained page (all blocks freed) visits as an empty area - let q: Vec<_> = (0..4).map(|_| mi::heap_malloc(heap, 99)).collect(); - for p in q { - mi::free(p); - } - assert!(mi::heap_visit_blocks(heap, true, &mut |_, _| true)); - - for p in live { - mi::free(p); - } - mi::heap_delete(heap); -} - -// --------------------------------------------------------------------------- -// Cross-thread huge free and abandoned reclaim-on-free -// --------------------------------------------------------------------------- - -#[test] -fn cross_thread_huge_free_resets_memory() { - // Freeing a huge (> LARGE_OBJ_SIZE_MAX) block from a foreign thread - // goes through huge_page_reset before the delayed-free push. - let p = mi::malloc(20 * MIB); - assert!(!p.is_null()); - unsafe { p.write_bytes(0x42, 4096) }; - let addr = p as usize; - std::thread::spawn(move || mi::free(addr as *mut u8)) - .join() - .unwrap(); - mi::collect(true); -} - -#[test] -fn delayed_free_via_full_page() { - // A remote free into a FULL page goes onto the owning heap's delayed - // list (UseDelayedFree); the owner's next collect drains it through - // heap_delayed_free_partial / free_delayed_block. - let mut v = Vec::new(); - for _ in 0..64 { - let p = mi::malloc(4000); // 16 blocks per 64 KiB page - assert!(!p.is_null()); - v.push(p as usize); - } - let first = v[0]; // belongs to a page that is full by now - std::thread::spawn(move || mi::free(first as *mut u8)) - .join() - .unwrap(); - mi::collect(false); - for addr in v.into_iter().skip(1) { - mi::free(addr as *mut u8); - } - mi::collect(false); -} - -#[test] -fn abandoned_reclaim_on_free() { - use rimalloc::options; - let prev = options::get(options::Option::AbandonedReclaimOnFree); - options::set(options::Option::AbandonedReclaimOnFree, 1); - - let ptrs = std::thread::spawn(|| { - let mut v = Vec::new(); - for _ in 0..64 { - let p = mi::malloc(2048); - assert!(!p.is_null()); - unsafe { p.write_bytes(0x66, 2048) }; - v.push(p as usize); - } - v // thread exits with live blocks: its segments are abandoned - }) - .join() - .unwrap(); - - // Warm this thread's allocator: reclaim-on-free only fires on an - // initialized thread. - mi::free(mi::malloc(1)); - let (_, tries_before, _) = mi::abandoned_stats(); - for addr in ptrs { - let p = addr as *mut u8; - assert!((0..2048).all(|i| unsafe { *p.add(i) } == 0x66)); - mi::free(p); // first free reclaims the abandoned segment - } - let (_, tries_after, _) = mi::abandoned_stats(); - assert!(tries_after >= tries_before); - options::set(options::Option::AbandonedReclaimOnFree, prev); - mi::collect(false); -} - -// --------------------------------------------------------------------------- -// Arena multi-block claims and purging; segment cache collection -// --------------------------------------------------------------------------- - -#[test] -fn arena_multi_block_and_purge() { - use rimalloc::options; - // multi-block arena claim: huge segments whose size lands on an exact - // multiple of the 32 MiB arena block are served by the arena bitmap - // (one of these sizes hits 64 MiB exactly, whatever the info-slice - // overhead); the rest go directly to the OS - for slack in 1..=4usize { - let p = mi::malloc(64 * MIB - slack * 64 * 1024); - assert!(!p.is_null()); - unsafe { p.write_bytes(1, 64) }; - mi::free(p); - } - let p = mi::malloc(40 * MIB); - assert!(!p.is_null()); - unsafe { p.write_bytes(1, 64) }; - mi::free(p); - - // schedule and force arena/cache purges - let prev = options::get(options::Option::PurgeDelay); - options::set(options::Option::PurgeDelay, 1); - let mut big = Vec::new(); - for _ in 0..4 { - big.push(mi::malloc(8 * MIB)); - } - for p in big.drain(..) { - mi::free(p); - } - std::thread::sleep(std::time::Duration::from_millis(50)); - mi::collect(false); // expiry-driven purge - mi::collect(true); // forced purge + cache flush back to the OS - options::set(options::Option::PurgeDelay, prev); -} - -#[test] -fn os_direct_huge_segments() { - // Huge segments above the arena bitmap's single-claim limit (> 2 GiB) - // and odd-sized huge segments come straight from the OS; check_owned on - // them exercises the segment-cookie path (no arena contains them). - let p = mi::malloc(36 * MIB); // odd size: not a 32 MiB multiple - assert!(!p.is_null()); - if !mi::is_in_heap_region(p) { - assert!(unsafe { mi::check_owned(p) }); - } - unsafe { p.write_bytes(3, 64) }; - mi::free(p); - mi::collect(true); // drain the deferred-free queue -} - -#[test] -fn diagnostics_counters() { - let p = mi::malloc(100); - mi::free(p); - let counts = mi::os_syscall_counts(); - assert!(counts[0] > 0); // at least one mmap happened by now - assert!(mi::current_thread_count_dbg() >= 1); - let (abandoned, tries, hits) = mi::abandoned_stats(); - assert!(hits <= tries || abandoned == 0 || tries == 0); -} - -// --------------------------------------------------------------------------- -// Stats and options surfaces -// --------------------------------------------------------------------------- - -#[test] -fn stats_report_renders() { - let p = mi::malloc(100); - mi::free(p); - let mut buf = Vec::new(); - mi::stats_write(&mut buf).unwrap(); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("heap stats:")); - assert!(s.contains("pages ops:")); - assert!(s.contains("segments")); - mi::stats_print(); // stderr variant - - // a writer failing at every possible point exercises each `?` arm - struct Failing(usize); - impl std::io::Write for Failing { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if self.0 == 0 { - return Err(std::io::Error::other("full")); - } - self.0 -= 1; - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - for budget in 0..32 { - let _ = mi::stats_write(&mut Failing(budget)); - } -} - -#[test] -fn options_set_get_enable_disable() { - use rimalloc::options; - let prev = options::get(options::Option::MaxPageCandidates); - options::set(options::Option::MaxPageCandidates, 9); - assert_eq!(options::get(options::Option::MaxPageCandidates), 9); - options::enable(options::Option::MaxPageCandidates); - assert!(options::is_enabled(options::Option::MaxPageCandidates)); - options::disable(options::Option::MaxPageCandidates); - assert!(!options::is_enabled(options::Option::MaxPageCandidates)); - options::set(options::Option::MaxPageCandidates, prev); -} - -// --------------------------------------------------------------------------- -// Thread lifecycle: explicit init/done and heaps left at thread exit -// --------------------------------------------------------------------------- - -#[test] -fn explicit_thread_init_done() { - std::thread::spawn(|| { - mi::thread_init(); - mi::thread_init(); // idempotent - let p = mi::malloc(100); - assert!(!p.is_null()); - mi::free(p); - mi::thread_done(); - mi::thread_done(); // idempotent - }) - .join() - .unwrap(); -} - -#[test] -fn thread_exit_deletes_extra_heaps() { - std::thread::spawn(|| { - let heap = mi::heap_new().expect("heap"); - for _ in 0..64 { - assert!(!mi::heap_malloc(heap, 512).is_null()); - } - // exit without heap_delete: thread teardown must absorb + abandon - }) - .join() - .unwrap(); - mi::collect(true); -} - -// --------------------------------------------------------------------------- -// Block sizes above 4 GiB (fastmod reciprocal unusable) -// --------------------------------------------------------------------------- - -#[test] -fn block_size_above_reciprocal_range() { - // > 4 GiB block: page.block_recip stays 0 and usable_size/free fall - // back to plain division. Virtual-only; pages are never touched beyond - // the first one. - let size = (4 << 30) + 4096usize; - let p = mi::malloc(size); - if p.is_null() { - return; // acceptable under memory pressure - } - unsafe { p.write_bytes(7, 8) }; - assert!(mi::usable_size(p) >= size); - mi::free(p); - mi::collect(true); -} diff --git a/vendor/rimalloc/tests/debug_fill.rs b/vendor/rimalloc/tests/debug_fill.rs deleted file mode 100644 index 61021634..00000000 --- a/vendor/rimalloc/tests/debug_fill.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Port of the `MI_DEBUG>=2` half of mimalloc `test/test-api-fill.c`: -//! fresh blocks carry the 0xD0 uninit pattern, freed blocks 0xDF. -#![cfg(feature = "debug-fill")] - -use rimalloc as mi; - -fn check_fill(p: *const u8, size: usize, byte: u8) -> bool { - (0..size).all(|i| unsafe { *p.add(i) } == byte) -} - -#[test] -fn uninit_malloc_small_and_large() { - for size in [8usize, 128, 1024, 16 * 1024] { - let p = mi::malloc(size); - assert!(check_fill(p, size, 0xD0), "uninit fill, size {size}"); - mi::free(p); - } -} - -#[test] -fn fill_freed_small() { - let size = 256usize; - let p = mi::malloc(size); - unsafe { std::ptr::write_bytes(p, 0xab, size) }; - mi::free(p); - // skip the first word: it now holds the free-list link - assert!(check_fill(unsafe { p.add(8) }, size - 8, 0xDF)); -} - -#[test] -fn zalloc_still_zero() { - let p = mi::zalloc(512); - assert!(check_fill(p, 512, 0)); - mi::free(p); -} diff --git a/vendor/rimalloc/tests/fill.rs b/vendor/rimalloc/tests/fill.rs deleted file mode 100644 index 94f471d3..00000000 --- a/vendor/rimalloc/tests/fill.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Port of mimalloc `test/test-api-fill.c` (the zero-init half; the -//! `MI_DEBUG>=2` fill-pattern half tests debug fill values we do not -//! implement, matching a release-mode mimalloc build). - -use rimalloc as mi; - -const SMALL_SIZE_MAX: usize = 128 * size_of::(); -const MAX_ALIGN_SIZE: usize = 16; - -fn check_zero_init(p: *const u8, size: usize) -> bool { - !p.is_null() && (0..size).all(|i| unsafe { *p.add(i) } == 0) -} - -#[test] -fn zeroinit_zalloc_small() { - let size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc(size); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_zalloc_large() { - let size = SMALL_SIZE_MAX * 2; - let p = mi::zalloc(size); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_zalloc_small_fn() { - let size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc_small(size); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_calloc_small() { - let size = SMALL_SIZE_MAX / 2; - let p = mi::calloc(size, 1); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_calloc_large() { - let size = SMALL_SIZE_MAX * 2; - let p = mi::calloc(size, 1); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_rezalloc_small() { - let mut size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc(size); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::rezalloc(p, size); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_rezalloc_large() { - let mut size = SMALL_SIZE_MAX * 2; - let p = mi::zalloc(size); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::rezalloc(p, size); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_recalloc_small() { - let mut size = SMALL_SIZE_MAX / 2; - let p = mi::calloc(size, 1); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::recalloc(p, size, 1); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_recalloc_large() { - let mut size = SMALL_SIZE_MAX * 2; - let p = mi::calloc(size, 1); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::recalloc(p, size, 1); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_zalloc_aligned_small() { - let size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_zalloc_aligned_large() { - let size = SMALL_SIZE_MAX * 2; - let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_calloc_aligned_small() { - let size = SMALL_SIZE_MAX / 2; - let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_calloc_aligned_large() { - let size = SMALL_SIZE_MAX * 2; - let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_rezalloc_aligned_small() { - let mut size = SMALL_SIZE_MAX / 2; - let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::rezalloc_aligned(p, size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_rezalloc_aligned_large() { - let mut size = SMALL_SIZE_MAX * 2; - let p = mi::zalloc_aligned(size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::rezalloc_aligned(p, size, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_recalloc_aligned_small() { - let mut size = SMALL_SIZE_MAX / 2; - let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::recalloc_aligned(p, size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} - -#[test] -fn zeroinit_recalloc_aligned_large() { - let mut size = SMALL_SIZE_MAX * 2; - let p = mi::calloc_aligned(size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - size *= 3; - let p = mi::recalloc_aligned(p, size, 1, MAX_ALIGN_SIZE * 2); - assert!(check_zero_init(p, size)); - mi::free(p); -} diff --git a/vendor/rimalloc/tests/fork.rs b/vendor/rimalloc/tests/fork.rs deleted file mode 100644 index 05ae0777..00000000 --- a/vendor/rimalloc/tests/fork.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! fork() safety: the child of a multithreaded allocating process must be -//! able to keep allocating (locks are held across fork and released in the -//! child; another thread's mid-critical-section state cannot leak in). -//! Miri cannot emulate `fork`, so this target is skipped under Miri. -#![cfg(all(unix, not(miri)))] - -use std::sync::atomic::{AtomicBool, Ordering}; - -#[test] -fn fork_while_allocating() { - // hammer the allocator (and its locks) from background threads - static STOP: AtomicBool = AtomicBool::new(false); - let workers: Vec<_> = (0..4) - .map(|t| { - std::thread::spawn(move || { - let mut keep = Vec::new(); - while !STOP.load(Ordering::Relaxed) { - let p = rimalloc::malloc((t + 1) * 137); - keep.push(p); - if keep.len() > 64 { - for q in keep.drain(..) { - rimalloc::free(q); - } - } - } - for q in keep { - rimalloc::free(q); - } - }) - }) - .collect(); - - for _ in 0..20 { - // SAFETY: child only uses the allocator and _exit (async-signal - // considerations handled by our atfork hooks). - let pid = unsafe { libc::fork() }; - assert!(pid >= 0, "fork failed"); - if pid == 0 { - // child: allocate/free across many size classes, then exit - let mut ptrs = Vec::new(); - for i in 0..2000usize { - let p = rimalloc::malloc((i % 3000) + 1); - if p.is_null() { - unsafe { libc::_exit(2) }; - } - unsafe { p.write_bytes(0x5c, (i % 3000) + 1) }; - ptrs.push(p); - } - for p in ptrs { - rimalloc::free(p); - } - let big = rimalloc::malloc(50 << 20); // force fresh segment work - if big.is_null() { - unsafe { libc::_exit(3) }; - } - rimalloc::free(big); - unsafe { libc::_exit(0) }; - } - let mut status = 0; - // SAFETY: valid pid from fork above. - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, - "child failed: status={status:#x}" - ); - } - - STOP.store(true, Ordering::Relaxed); - for w in workers { - w.join().unwrap(); - } -} diff --git a/vendor/rimalloc/tests/lean_conformance.rs b/vendor/rimalloc/tests/lean_conformance.rs deleted file mode 100644 index 2f5b25d8..00000000 --- a/vendor/rimalloc/tests/lean_conformance.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Conformance against the Lean-verified model (`verify/`). -//! -//! The Lean project proves theorems about a reference model; these tests -//! pin the live Rust implementation to that model over the *entire* input -//! domain. Refactor the Rust freely: if it still passes here, every Lean -//! theorem (fit, <25% fragmentation, monotonicity, range, round-trip) -//! transfers to the new code. Regenerate the vectors (`lake exe -//! genvectors`) only when the spec itself changes. - -use rimalloc::lean_model; - -#[cfg(not(windows))] -#[test] -fn bins_match_lean_model() { - let csv = include_str!("../../../verify/vectors/bins.csv"); - let mut checked = 0; - for line in csv.lines() { - let (w, bin) = line.split_once(',').expect("w,bin"); - let w: usize = w.parse().unwrap(); - let expect: usize = bin.parse().unwrap(); - assert_eq!( - lean_model::bin_from_wsize(w), - expect, - "Bin::from_size diverged from the verified model at wsize {w}" - ); - checked += 1; - } - assert_eq!(checked, 8194); // the full domain plus one past it -} - -#[cfg(not(windows))] -#[test] -fn bin_sizes_match_lean_model() { - let csv = include_str!("../../../verify/vectors/bin_sizes.csv"); - for line in csv.lines() { - let (bin, wsize) = line.split_once(',').expect("bin,wsize"); - let bin: usize = bin.parse().unwrap(); - let expect: usize = wsize.parse().unwrap(); - assert_eq!( - lean_model::bin_wsize(bin), - expect, - "BIN_WSIZE diverged from the verified model at bin {bin}" - ); - } -} - -#[test] -fn commit_mask_matches_closed_form() { - // Lean proves the create loop equals ((1 << count) - 1) << idx - // (`CommitMask.create_correct`); check the live Rust loop against the - // same closed form for every valid (idx, count) pair. Under Miri the - // ~67M-assert grid is sampled with a coprime stride (the full grid - // still runs natively, and the property is Lean-proven). - let step = if cfg!(miri) { 37 } else { 1 }; - for idx in (0..=512usize).step_by(step) { - for count in (0..=(512 - idx)).step_by(step) { - let mask = lean_model::commit_mask_create_words(idx, count); - for bit in 0..512 { - let expect = bit >= idx && bit < idx + count; - let got = mask[bit / 64] & (1 << (bit % 64)) != 0; - assert_eq!(got, expect, "create({idx},{count}) bit {bit}"); - } - } - } -} - -#[test] -fn page_start_offset_matches_lean_contract() { - // PageStart.lean proves three properties of the offset computation over - // every reachable bin <= MAX_ALIGN_GUARANTEE and every 16-aligned - // page-start residue; check the live function against the same - // contract over the same (exhaustive) domain. - for bin in 1..49usize { - let bs = lean_model::bin_wsize(bin) * 8; - let reachable = lean_model::bin_from_wsize(lean_model::bin_wsize(bin)) == bin; - let psize = if bs <= 8192 { 65536 } else { 524288 }; - for k in 0..4096usize { - let pstart = 16 * k; - let off = lean_model::page_start_offset(bs, pstart, psize); - // pageStart_align16 - assert_eq!(off % 16, 0, "16-alignment, bs={bs} pstart={pstart}"); - if reachable { - // pageStart_blockAligned - assert_eq!( - (pstart + off) % bs, - 0, - "block alignment guarantee, bs={bs} pstart={pstart}" - ); - // pageStart_fits - assert!(off + bs <= psize, "fits, bs={bs} pstart={pstart}"); - } - } - } -} diff --git a/vendor/rimalloc/tests/loom_arena.rs b/vendor/rimalloc/tests/loom_arena.rs deleted file mode 100644 index ddbfd902..00000000 --- a/vendor/rimalloc/tests/loom_arena.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Loom model-check of the arena claim/commit/purge bitmap protocol. -//! -//! This drives the *actual implementation* — `rimalloc::bitmap::{ -//! claim_single_field, claim_run_field, prepare_run, release_run, -//! clear_committed_run, purge_field, decommit_run}`, the same generic -//! functions production `arena.rs` executes — instantiated with loom -//! atomics, so every CAS, ordering, and bit transition of the real lock-free -//! bitmap code is explored across all interleavings. Only the OS side -//! effects are test-side: `commit`/`decommit` set/clear a per-block shadow -//! flag (loom cannot model real mmap) and "use" asserts that flag, mirroring -//! how production supplies the real syscalls through the same closures. -//! -//! Invariants asserted across all interleavings: -//! 1. Mutual exclusion — no two threads hold the same block at once (the -//! lost-update / double-claim hazard, lock-free single-block CAS path and -//! the multi-block spinlock-scan path). -//! 2. Commit-before-use (the SIGBUS class) — a claimer never reads a block -//! whose committed shadow is clear without having recommitted; the -//! `blocks_committed` bit and the real commit state never diverge to hand -//! out PROT_NONE memory. -//! 3. Purge/claim non-interference — a block being decommitted by -//! `purge_field`/`decommit_run` is never simultaneously used by a claimer. -//! -//! Run: LOOM_MAX_PREEMPTIONS=3 RUSTFLAGS="--cfg loom" \ -//! cargo test -p rimalloc --test loom_arena --release -#![cfg(loom)] - -use loom::cell::UnsafeCell; -use loom::sync::Arc; -use loom::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use loom::thread; -use rimalloc::bitmap::{ - BitmapWord, claim_run_field, claim_single_field, clear_committed_run, decommit_run, - prepare_run, purge_field, release_run, -}; - -/// Loom-instrumented atomic word for the bitmap's generic interface. -struct LoomWord(AtomicUsize); -impl LoomWord { - fn new(v: usize) -> LoomWord { - LoomWord(AtomicUsize::new(v)) - } -} -impl BitmapWord for LoomWord { - fn load(&self, o: Ordering) -> usize { - self.0.load(o) - } - fn store(&self, v: usize, o: Ordering) { - self.0.store(v, o) - } - fn swap(&self, v: usize, o: Ordering) -> usize { - self.0.swap(v, o) - } - fn fetch_or(&self, v: usize, o: Ordering) -> usize { - self.0.fetch_or(v, o) - } - fn fetch_and(&self, v: usize, o: Ordering) -> usize { - self.0.fetch_and(v, o) - } - fn compare_exchange_weak( - &self, - cur: usize, - new: usize, - ok: Ordering, - err: Ordering, - ) -> Result { - self.0.compare_exchange_weak(cur, new, ok, err) - } -} - -/// One arena field's worth of bitmap state plus the test-side shadows that -/// stand in for real OS memory. `BLOCKS` blocks in a single field. -struct Field { - inuse: LoomWord, - dirty: LoomWord, - committed: LoomWord, - purge: LoomWord, - claim_lock: AtomicBool, - /// Per-block shadow of the *real* commit state: `commit` sets it, - /// `decommit` clears it, "use" asserts it. Divergence from the - /// `committed` bitmap in a way that lets a claimer use a `false` block is - /// the SIGBUS bug. - shadow_committed: Vec>, - /// Per-block live-owner guard for mutual exclusion: a claimer that thinks - /// it owns a block flips this true; two simultaneous owners is a - /// double-claim. Guarded solely by the bitmap CAS, never atomically. - owned: Vec>, -} - -// SAFETY: all sharing is mediated by the loom atomics / cells. -unsafe impl Sync for Field {} -unsafe impl Send for Field {} - -impl Field { - fn new(blocks: usize, committed: bool) -> Field { - Field { - inuse: LoomWord::new(0), - dirty: LoomWord::new(0), - committed: LoomWord::new(if committed { (1 << blocks) - 1 } else { 0 }), - purge: LoomWord::new(0), - claim_lock: AtomicBool::new(false), - shadow_committed: (0..blocks).map(|_| UnsafeCell::new(committed)).collect(), - owned: (0..blocks).map(|_| UnsafeCell::new(false)).collect(), - } - } - - /// `commit` side effect: set the shadow flags for the run. - fn shadow_commit(&self, bit: usize, count: usize) { - for b in bit..bit + count { - self.shadow_committed[b].with_mut(|p| unsafe { *p = true }); - } - } - - /// `decommit` side effect: clear the shadow flags for the run. - fn shadow_decommit(&self, bit: usize, count: usize) { - for b in bit..bit + count { - self.shadow_committed[b].with_mut(|p| unsafe { *p = false }); - } - } - - /// Claim + prepare + use + release one block via the *real* lock-free - /// single-block path, asserting all three invariants. - fn claim_use_single(&self, blocks: usize) { - let Some(bit) = claim_single_field(&self.inuse, blocks) else { - return; // field full this interleaving - }; - // Invariant 1: we are the sole owner now. - self.owned[bit].with_mut(|p| unsafe { - assert!(!*p, "double-claim: block {bit} already owned"); - *p = true; - }); - // prepare: recommit iff committed bit was clear. - prepare_run(&self.dirty, &self.committed, &self.purge, bit, 1, || { - self.shadow_commit(bit, 1) - }); - // Invariant 2: the block we are about to "use" is really committed. - self.shadow_committed[bit] - .with(|p| unsafe { assert!(*p, "commit-before-use violated: block {bit} (SIGBUS)") }); - // Invariant 3 also holds here: purge_field/decommit_run can only act - // on blocks whose inuse bit is clear, and ours is set for this whole - // window. - self.owned[bit].with_mut(|p| unsafe { *p = false }); - release_run(&self.inuse, &self.purge, bit, 1); - } - - /// Claim + prepare + use + release a `count`-wide run via the *real* - /// multi-block spinlock-scan path. - fn claim_use_run(&self, blocks: usize, count: usize) { - rimalloc_spin_acquire(&self.claim_lock); - let found = claim_run_field(&self.inuse, blocks, count); - rimalloc_spin_release(&self.claim_lock); - let Some(bit) = found else { - return; - }; - for b in bit..bit + count { - self.owned[b].with_mut(|p| unsafe { - assert!(!*p, "double-claim (multi): block {b} already owned"); - *p = true; - }); - } - prepare_run( - &self.dirty, - &self.committed, - &self.purge, - bit, - count, - || self.shadow_commit(bit, count), - ); - for b in bit..bit + count { - self.shadow_committed[b].with(|p| unsafe { - assert!(*p, "commit-before-use violated: block {b} (SIGBUS, multi)") - }); - } - for b in bit..bit + count { - self.owned[b].with_mut(|p| unsafe { *p = false }); - } - release_run(&self.inuse, &self.purge, bit, count); - } - - /// `arena::free(.., all_committed=false)`: a dying segment decommitted - /// part of its range, so it clears the committed bits *and* the shadow - /// for the run, then releases. Models the cross-layer fix's caller. - fn free_partial(&self, bit: usize, count: usize) { - clear_committed_run(&self.committed, bit, count); - self.shadow_decommit(bit, count); - release_run(&self.inuse, &self.purge, bit, count); - } - - /// `purge_now` for this single field, decommit mode: the real - /// `purge_field` + `decommit_run` handshake. The `decommit` closure - /// clears the shadow; invariant 3 is that no claimer uses a block while - /// its shadow is being cleared — guaranteed because `decommit_run` holds - /// the inuse bit across the whole shadow clear, so any concurrent - /// claimer's `prepare_run` (which sees committed clear) recommits before - /// using, and any claimer that grabbed the bit first keeps `decommit_run` - /// from acting on it. - fn purge(&self) { - purge_field(&self.purge, &self.inuse, |bit, run_len| { - decommit_run(&self.inuse, &self.committed, bit, run_len, || { - self.shadow_decommit(bit, run_len) - }); - }); - } - - /// Final consistency check: every committed *bit* must agree with its - /// shadow (no block advertised committed in the bitmap while physically - /// decommitted), and nothing is left owned. - fn assert_consistent(&self, blocks: usize) { - let inuse = self.inuse.load(Ordering::Relaxed); - assert_eq!(inuse, 0, "blocks left in use"); - let committed = self.committed.load(Ordering::Relaxed); - for b in 0..blocks { - self.owned[b].with(|p| unsafe { assert!(!*p, "block {b} left owned") }); - let bit_set = committed & (1 << b) != 0; - let shadow = self.shadow_committed[b].with(|p| unsafe { *p }); - if bit_set { - assert!( - shadow, - "committed bitmap advertises block {b} but shadow says decommitted" - ); - } - } - } -} - -/// Loom-instrumented copy of `crate::sync::spin_{acquire,release}` (the lock -/// is `private`; this is byte-identical to the production spinlock so the -/// multi-block claim path is modeled exactly). -fn rimalloc_spin_acquire(lock: &AtomicBool) { - while lock - .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_err() - { - loom::hint::spin_loop(); - } -} -fn rimalloc_spin_release(lock: &AtomicBool) { - lock.store(false, Ordering::Release); -} - -/// Two threads race lock-free single-block claims on a 2-block field that -/// starts fully committed. Mutual exclusion + commit-before-use. -#[test] -fn claim_vs_claim_single() { - loom::model(|| { - let f = Arc::new(Field::new(2, true)); - let t1 = { - let f = f.clone(); - thread::spawn(move || f.claim_use_single(2)) - }; - let t2 = { - let f = f.clone(); - thread::spawn(move || f.claim_use_single(2)) - }; - t1.join().unwrap(); - t2.join().unwrap(); - f.assert_consistent(2); - }); -} - -/// A claimer racing a purge: block 0 starts free+committed but scheduled for -/// purge; one thread claims-and-uses it (must recommit if it observes the -/// committed bit cleared by the purge), the other decommits it. This is the -/// exact SIGBUS class — re-claiming a partially-decommitted block. -#[test] -fn claim_vs_purge() { - loom::model(|| { - // 2 blocks, both committed; block 0 scheduled for purge. - let f = Arc::new(Field::new(2, true)); - f.purge.store(1, Ordering::Relaxed); // block 0 pending purge - let claimer = { - let f = f.clone(); - thread::spawn(move || f.claim_use_single(2)) - }; - let purger = { - let f = f.clone(); - thread::spawn(move || f.purge()) - }; - claimer.join().unwrap(); - purger.join().unwrap(); - f.assert_consistent(2); - }); -} - -/// free-partial (clears committed bit + shadow) racing a claim-and-use of the -/// freed block: the claimer that re-grabs the block must observe the cleared -/// committed bit and recommit before using. This is the cross-layer fix. -#[test] -fn free_partial_vs_claim() { - loom::model(|| { - // 1 block, starts owned (inuse) and committed. - let f = Arc::new(Field::new(1, true)); - f.inuse.store(1, Ordering::Relaxed); - f.dirty.store(1, Ordering::Relaxed); - let freer = { - let f = f.clone(); - thread::spawn(move || f.free_partial(0, 1)) - }; - let claimer = { - let f = f.clone(); - thread::spawn(move || f.claim_use_single(1)) - }; - freer.join().unwrap(); - claimer.join().unwrap(); - f.assert_consistent(1); - }); -} - -/// Multi-block claim (spinlock path) racing a single-block claim on a 2-block -/// field: the run claim wants both blocks; the single claim wants one. They -/// must not overlap. -#[test] -fn claim_run_vs_single() { - loom::model(|| { - let f = Arc::new(Field::new(2, true)); - let runner = { - let f = f.clone(); - thread::spawn(move || f.claim_use_run(2, 2)) - }; - let single = { - let f = f.clone(); - thread::spawn(move || f.claim_use_single(2)) - }; - runner.join().unwrap(); - single.join().unwrap(); - f.assert_consistent(2); - }); -} diff --git a/vendor/rimalloc/tests/loom_protocol.rs b/vendor/rimalloc/tests/loom_protocol.rs deleted file mode 100644 index e8aae06d..00000000 --- a/vendor/rimalloc/tests/loom_protocol.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Loom model-check of the cross-thread delayed-free protocol. -//! -//! This drives the *actual implementation* — `rimalloc::protocol::{mt_push, -//! collect_take, try_use_delayed, delayed_partial}`, the same generic -//! functions production `free.rs`/`page_ops.rs` execute — instantiated -//! with loom atomics, so every CAS, ordering, and flag transition of the -//! real code is explored across all interleavings. Only block *storage* -//! (next links, the owner's counters) is test-side, mirroring how -//! production supplies raw page memory through the same closures. -//! -//! Checked: every remotely freed block lands on the owner's local list -//! exactly once (none lost, none duplicated), `used` reaches exactly zero, -//! and FREEING never leaks. -//! -//! Run: RUSTFLAGS="--cfg loom" cargo test --test loom_protocol --release -#![cfg(loom)] - -use loom::cell::UnsafeCell; -use loom::sync::Arc; -use loom::sync::atomic::Ordering; -use loom::thread; -use rimalloc::protocol::{ - AtomicWord, FREEING, NO_DELAYED, TF_COUNT_SHIFT, TF_PTR_MASK, USE_DELAYED, collect_take, - delayed_partial, mt_push, try_use_delayed, -}; - -/// Loom-instrumented atomic word for the protocol's generic interface. -struct LoomWord(loom::sync::atomic::AtomicUsize); -impl AtomicWord for LoomWord { - fn load(&self, o: core::sync::atomic::Ordering) -> usize { - self.0.load(o) - } - fn store(&self, v: usize, o: core::sync::atomic::Ordering) { - self.0.store(v, o) - } - fn swap(&self, v: usize, o: core::sync::atomic::Ordering) -> usize { - self.0.swap(v, o) - } - fn compare_exchange_weak( - &self, - cur: usize, - new: usize, - ok: core::sync::atomic::Ordering, - err: core::sync::atomic::Ordering, - ) -> Result { - self.0.compare_exchange_weak(cur, new, ok, err) - } -} - -/// Test-side storage standing in for raw page memory: block addresses are -/// `4 * (index + 1)` so flag bits never collide and 0 stays null. -struct Storage { - next: Vec>, - xthread: LoomWord, - delayed: LoomWord, - used: UnsafeCell, - local: UnsafeCell>, -} -unsafe impl Sync for Storage {} -unsafe impl Send for Storage {} - -const fn addr(i: usize) -> usize { - 4 * (i + 1) -} -fn index(a: usize) -> usize { - a / 4 - 1 -} - -impl Storage { - fn new(n: usize, flag: usize) -> Storage { - Storage { - next: (0..n).map(|_| UnsafeCell::new(0)).collect(), - xthread: LoomWord(loom::sync::atomic::AtomicUsize::new(flag)), - delayed: LoomWord(loom::sync::atomic::AtomicUsize::new(0)), - used: UnsafeCell::new(n), - local: UnsafeCell::new(Vec::new()), - } - } - - /// `free_block_delayed_mt` = the real `mt_push` + raw storage closures. - fn remote_free(&self, i: usize) { - mt_push( - &self.xthread, - || Some(&self.delayed), - addr(i), - |next| self.next[i].with_mut(|p| unsafe { *p = next }), - ); - } - - /// `page_thread_free_collect` = the real `collect_take` + list walk. - /// The walk must agree with the count packed in the taken word. - fn collect(&self) { - let taken = collect_take(&self.xthread); - let mut n = taken & TF_PTR_MASK; - let mut count = 0usize; - while n != 0 { - count += 1; - let next = self.next[index(n)].with(|p| unsafe { *p }); - self.local.with_mut(|l| unsafe { (*l).push(n) }); - n = next; - } - assert_eq!(taken >> TF_COUNT_SHIFT, count, "packed count wrong"); - self.used.with_mut(|u| unsafe { - assert!(*u >= count, "used underflow in collect"); - *u -= count; - }); - } - - /// `free_delayed_block` = the real `try_use_delayed` + collect + - /// local bookkeeping. - fn free_delayed_block(&self, a: usize) -> bool { - if !try_use_delayed(&self.xthread, USE_DELAYED, false, thread::yield_now) { - return false; - } - self.collect(); - self.local.with_mut(|l| unsafe { (*l).push(a) }); - self.used.with_mut(|u| unsafe { - assert!(*u >= 1, "used underflow (delayed)"); - *u -= 1; - }); - true - } - - /// `heap_delayed_free_partial` = the real `delayed_partial`. - fn drain_delayed(&self) -> bool { - delayed_partial( - &self.delayed, - |a| self.next[index(a)].with(|p| unsafe { *p }), - |a, next| self.next[index(a)].with_mut(|p| unsafe { *p = next }), - |a| self.free_delayed_block(a), - ) - } - - fn assert_quiescent(&self, n: usize) { - for _ in 0..4 { - self.drain_delayed(); - self.collect(); - } - assert_eq!( - self.delayed.load(Ordering::Relaxed), - 0, - "delayed not drained" - ); - let x = self.xthread.load(Ordering::Relaxed); - assert_ne!(x & 3, FREEING, "FREEING leaked"); - assert_eq!(x & !3, 0, "page list not drained"); - self.used - .with(|u| unsafe { assert_eq!(*u, 0, "used not zero") }); - self.local.with(|l| unsafe { - let mut v = (*l).clone(); - v.sort(); - assert_eq!( - v, - (0..n).map(addr).collect::>(), - "blocks lost or duplicated" - ); - }); - } -} - -/// Full page (USE_DELAYED): the first remote free must take the FREEING -/// handshake and wake the owner via the delayed list; nothing may be lost -/// between the two lists. -#[test] -fn full_page_wakeup() { - loom::model(|| { - let s = Arc::new(Storage::new(2, USE_DELAYED)); - let r1 = { - let s = s.clone(); - thread::spawn(move || s.remote_free(0)) - }; - let r2 = { - let s = s.clone(); - thread::spawn(move || s.remote_free(1)) - }; - let owner = { - let s = s.clone(); - thread::spawn(move || { - s.drain_delayed(); - }) - }; - r1.join().unwrap(); - r2.join().unwrap(); - owner.join().unwrap(); - s.assert_quiescent(2); - }); -} - -/// Normal page (NO_DELAYED): remote frees race the owner's collect on the -/// page-local thread-free list. -#[test] -fn page_list_race() { - loom::model(|| { - let s = Arc::new(Storage::new(2, NO_DELAYED)); - let r1 = { - let s = s.clone(); - thread::spawn(move || s.remote_free(0)) - }; - let r2 = { - let s = s.clone(); - thread::spawn(move || s.remote_free(1)) - }; - let owner = { - let s = s.clone(); - thread::spawn(move || { - s.collect(); - s.drain_delayed(); - }) - }; - r1.join().unwrap(); - r2.join().unwrap(); - owner.join().unwrap(); - s.assert_quiescent(2); - }); -} diff --git a/vendor/rimalloc/tests/mt_stress.rs b/vendor/rimalloc/tests/mt_stress.rs deleted file mode 100644 index c3056a4d..00000000 --- a/vendor/rimalloc/tests/mt_stress.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Deterministic multithreaded stress: the same op-program interpreter as -//! the `mt_ops` fuzz target, driven by fixed PRNG-generated programs so the -//! cross-thread paths (`free_generic_mt`, thread-exit abandonment, -//! reclaim-on-free) run under `cargo test` — and, critically, under the -//! TSan battery (`scripts/tsan.sh`), where loom's extracted-protocol model -//! cannot see segment reclaim, arena bitmaps, or page lifecycle. - -#![cfg(not(miri))] // covered by loom/fuzz; threads under Miri are too slow - -use std::sync::mpsc; - -struct Block(*mut u8, usize, u8); -unsafe impl Send for Block {} - -fn fill(p: *mut u8, n: usize, pat: u8) { - unsafe { core::ptr::write_bytes(p, pat, n.min(4096)) } -} - -fn check(b: &Block) { - for i in 0..b.1.min(4096) { - assert_eq!( - unsafe { *b.0.add(i) }, - b.2, - "corruption in cross-thread block" - ); - } -} - -/// The `mt_ops` fuzz-target body, verbatim. -fn run_program(data: &[u8]) { - if data.len() < 4 { - return; - } - let n_remote = (data[0] % 3 + 1) as usize; - let ops = &data[1..]; - - let mut to_remote: Vec> = Vec::new(); - let mut handles = Vec::new(); - let (back_tx, back_rx) = mpsc::channel::(); - for t in 0..n_remote { - let (tx, rx) = mpsc::channel::(); - to_remote.push(tx); - let back = back_tx.clone(); - let seed = data.get(2 + t).copied().unwrap_or(7); - handles.push(std::thread::spawn(move || { - let mut k = 0u8; - while let Ok(b) = rx.recv() { - check(&b); - rimalloc::free(b.0); - let size = ((seed as usize * 131 + k as usize * 977) % 9000) + 1; - let p = rimalloc::malloc(size); - if !p.is_null() { - let pat = seed ^ k | 1; - fill(p, size, pat); - if k % 3 == 0 { - let _ = back.send(Block(p, size, pat)); - } else { - check(&Block(p, size, pat)); - rimalloc::free(p); - } - } - k = k.wrapping_add(1); - } - })); - } - drop(back_tx); - - let mut local: Vec = Vec::new(); - let mut pat = 0x10u8; - for (i, &op) in ops.iter().enumerate() { - match op % 5 { - 0 | 1 => { - let size = ((op as usize) << 5 | i & 31) + 1; - let p = rimalloc::malloc(size); - if !p.is_null() { - pat = pat.wrapping_add(1) | 1; - fill(p, size, pat); - let _ = to_remote[i % n_remote].send(Block(p, size, pat)); - } - } - 2 => { - let size = (op as usize * 67 % 16384) + 1; - let p = rimalloc::malloc(size); - if !p.is_null() { - pat = pat.wrapping_add(1) | 1; - fill(p, size, pat); - local.push(Block(p, size, pat)); - } - } - 3 => { - if let Ok(b) = back_rx.try_recv() { - check(&b); - rimalloc::free(b.0); - } - } - _ => { - if !local.is_empty() { - let b = local.swap_remove(op as usize % local.len()); - check(&b); - rimalloc::free(b.0); - } - } - } - } - - drop(to_remote); - for h in handles { - h.join().unwrap(); - } - while let Ok(b) = back_rx.recv() { - check(&b); - rimalloc::free(b.0); - } - for b in local.drain(..) { - check(&b); - rimalloc::free(b.0); - } - rimalloc::collect(false); -} - -fn splitmix64(state: &mut u64) -> u64 { - *state = state.wrapping_add(0x9e3779b97f4a7c15); - let mut z = *state; - z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); - z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); - z ^ (z >> 31) -} - -fn program(seed: u64, len: usize) -> Vec { - let mut state = seed; - (0..len).map(|_| splitmix64(&mut state) as u8).collect() -} - -#[test] -fn mt_ops_programs() { - for seed in 0..8u64 { - run_program(&program(seed, 512)); - } -} - -#[test] -fn mt_ops_long_program() { - run_program(&program(0xdead_beef, 4096)); -} diff --git a/vendor/rimalloc/tests/no_arena_cache.rs b/vendor/rimalloc/tests/no_arena_cache.rs deleted file mode 100644 index 5997c67d..00000000 --- a/vendor/rimalloc/tests/no_arena_cache.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Exercises the OS segment cache, which is only reachable when arena -//! reservation is disabled (`MIMALLOC_ARENA_RESERVE=0`). Lives in its own -//! test binary: options latch on first read, so the env must be set -//! before the first allocator call in this process. - -#[test] -fn segment_cache_serves_reuse_without_arenas() { - // SAFETY: single test in this binary; no concurrent env access. - unsafe { - std::env::set_var("MIMALLOC_ARENA_RESERVE", "0"); - std::env::set_var("MIMALLOC_PURGE_DELAY", "0"); - } - - // Wave 1: force several segments into existence, then free everything - // so the segments retire into the OS cache. - let mut ptrs: Vec<*mut u8> = (0..64) - .map(|i| rimalloc::malloc(64 * 1024 + i * 64)) - .collect(); - for &p in &ptrs { - assert!(!p.is_null()); - unsafe { p.write_bytes(0xAB, 64) }; - } - for p in ptrs.drain(..) { - rimalloc::free(p); - } - rimalloc::collect(true); - let [mmap1, ..] = rimalloc::os_syscall_counts(); - - // Wave 2: the same demand must be served from the cache, not mmap. - let ptrs: Vec<*mut u8> = (0..64) - .map(|i| rimalloc::malloc(64 * 1024 + i * 64)) - .collect(); - for &p in &ptrs { - assert!(!p.is_null()); - } - let [mmap2, ..] = rimalloc::os_syscall_counts(); - assert!( - mmap2 <= mmap1 + 2, - "cache miss storm: {mmap1} mmaps before wave 2, {mmap2} after" - ); - for p in ptrs { - rimalloc::free(p); - } - - // Expire and purge the cached segments (purge_delay=0 → immediate). - rimalloc::collect(true); - - // Huge allocations bypass the cache (non-standard size) but must still - // work without arenas. - let huge = rimalloc::malloc(40 << 20); - assert!(!huge.is_null()); - unsafe { huge.write_bytes(1, 4096) }; - rimalloc::free(huge); -} diff --git a/vendor/rimalloc/tests/options_env.rs b/vendor/rimalloc/tests/options_env.rs deleted file mode 100644 index 50da6477..00000000 --- a/vendor/rimalloc/tests/options_env.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Environment-variable parsing for runtime options. Lives in its own -//! integration-test binary with a single test: the env must be set before -//! any option is first read (each option caches its value on first `get`), -//! and `set_var` must not race other test threads. - -use rimalloc::options::{self, Option as Opt}; - -#[test] -fn env_values_parse_with_fallback() { - // SAFETY: single test in this binary; no other thread reads the - // environment concurrently. - unsafe { - std::env::set_var("MIMALLOC_VERBOSE", "on"); - std::env::set_var("MIMALLOC_SHOW_STATS", "NO"); - std::env::set_var("MIMALLOC_SHOW_ERRORS", "3"); - std::env::set_var("MIMALLOC_PURGE_EXTEND_DELAY", ""); - std::env::set_var("MIMALLOC_EAGER_COMMIT_DELAY", "garbage"); - } - assert_eq!(options::get(Opt::Verbose), 1); // "on" => 1 - assert_eq!(options::get(Opt::ShowStats), 0); // "NO" => 0 (case-insensitive) - assert_eq!(options::get(Opt::ShowErrors), 3); // numeric - assert_eq!(options::get(Opt::PurgeExtendDelay), 1); // "" => 1 - assert_eq!(options::get(Opt::EagerCommitDelay), 0); // unparsable => default - // unset env: default applies - assert_eq!(options::get(Opt::FullPageRetain), 2); -} diff --git a/vendor/rimalloc/tests/parity.rs b/vendor/rimalloc/tests/parity.rs deleted file mode 100644 index e3633caf..00000000 --- a/vendor/rimalloc/tests/parity.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Tests for arena reservation/binding, NUMA plumbing, and registered hooks. - -use std::sync::atomic::{AtomicUsize, Ordering}; - -use rimalloc as mi; - -#[test] -fn reserve_and_heap_in_arena() { - // Reserve an exclusive arena and bind a heap to it: allocations from - // that heap must come from inside the reservation; the default heap - // must never allocate from it. - const ARENA_SIZE: usize = 64 * 1024 * 1024; // 2 blocks - let arena_id = mi::reserve_os_memory(ARENA_SIZE, false, true).expect("reserve"); - assert!(arena_id > 0); - - let heap = mi::heap_new_in_arena(arena_id).expect("heap"); - let inside = mi::heap_malloc(heap, 1234); - assert!(!inside.is_null()); - - // exhaust nothing on the default heap; its memory must be elsewhere - let outside = mi::malloc(1234); - assert!(!outside.is_null()); - - // the exclusive arena served the bound heap - let p1 = mi::heap_malloc(heap, 100_000); - let p2 = mi::heap_malloc(heap, 8); - for p in [inside, p1, p2] { - assert!(!p.is_null()); - } - // all blocks of the bound heap live in the same 32MiB-aligned segment - // region; segments of the two heaps must differ - let seg = |p: *mut u8| p.addr() & !(32 * 1024 * 1024 - 1); - assert_ne!(seg(inside), seg(outside)); - - mi::free(p1); - mi::free(p2); - mi::heap_delete(heap); - mi::free(outside); -} - -#[cfg(not(windows))] -#[test] -fn manage_external_memory() { - // Hand rimalloc a raw mapping; it must become an allocatable arena. - const SIZE: usize = 96 * 1024 * 1024; - let raw = unsafe { - libc::mmap( - std::ptr::null_mut(), - SIZE, - libc::PROT_READ | libc::PROT_WRITE, - libc::MAP_PRIVATE | libc::MAP_ANON, - -1, - 0, - ) - }; - assert_ne!(raw, libc::MAP_FAILED); - let arena_id = - unsafe { mi::manage_os_memory(raw.cast(), SIZE, true, true, -1, true).expect("manage") }; - let heap = mi::heap_new_in_arena(arena_id).expect("heap"); - let p = mi::heap_malloc(heap, 4096); - assert!(!p.is_null()); - assert!(p.addr() >= raw.addr() && p.addr() < raw.addr() + SIZE); - mi::free(p); - mi::heap_delete(heap); -} - -#[test] -fn huge_os_pages_api() { - // Unsupported on macOS: must fail gracefully, not crash. - let r = mi::reserve_huge_os_pages_at(1, 0); - if cfg!(target_os = "linux") { - // may succeed or fail depending on hugetlb pool; both are valid - let _ = r; - } else { - assert!(r.is_none()); - } -} - -static DEFERRED_CALLS: AtomicUsize = AtomicUsize::new(0); -static ERROR_CALLS: AtomicUsize = AtomicUsize::new(0); -static OUTPUT_BYTES: AtomicUsize = AtomicUsize::new(0); - -extern "C" fn on_deferred(_force: bool, _heartbeat: u64, _arg: *mut core::ffi::c_void) { - DEFERRED_CALLS.fetch_add(1, Ordering::Relaxed); -} -extern "C" fn on_error(_code: i32, _arg: *mut core::ffi::c_void) { - ERROR_CALLS.fetch_add(1, Ordering::Relaxed); -} -extern "C" fn on_output(_msg: *const u8, len: usize, _arg: *mut core::ffi::c_void) { - OUTPUT_BYTES.fetch_add(len, Ordering::Relaxed); -} - -#[test] -fn registered_hooks_fire() { - mi::register_deferred_free(Some(on_deferred), std::ptr::null_mut()); - mi::register_output(Some(on_output), std::ptr::null_mut()); - mi::register_error(Some(on_error), std::ptr::null_mut()); - - // deferred-free fires on collect - mi::collect(false); - assert!(DEFERRED_CALLS.load(Ordering::Relaxed) >= 1); - - // an oversized allocation reports through output+error hooks - let p = mi::malloc(isize::MAX as usize + 1); - assert!(p.is_null()); - assert!(ERROR_CALLS.load(Ordering::Relaxed) >= 1); - assert!(OUTPUT_BYTES.load(Ordering::Relaxed) > 0); - - mi::register_deferred_free(None, std::ptr::null_mut()); - mi::register_output(None, std::ptr::null_mut()); - mi::register_error(None, std::ptr::null_mut()); -} - -#[test] -fn exclusive_arena_never_escapes_to_os() { - // A 64 MiB exclusive arena holds exactly 2 segment blocks. A heap bound - // to it must (a) serve multi-block huge segments from the arena (sizes - // rounded up to whole blocks) and (b) return null once the arena is - // exhausted, never falling back to OS memory: this regressed into an - // infinite segment-allocation loop before segment_os_alloc refused the - // OS fallback for bound heaps. - const MIB: usize = 1024 * 1024; - let arena = mi::reserve_os_memory(64 * MIB, true, true).expect("arena"); - let heap = mi::heap_new_in_arena(arena).expect("heap"); - - let big = mi::heap_malloc(heap, 40 * MIB); // 2-block arena claim - assert!(!big.is_null() && mi::is_in_heap_region(big)); - unsafe { big.write_bytes(1, 64) }; - mi::free(big); - - let pinned: Vec<_> = (0..6).map(|_| mi::heap_malloc(heap, 10 * MIB)).collect(); - assert!( - pinned - .iter() - .all(|&p| !p.is_null() && mi::is_in_heap_region(p)) - ); - - // arena exhausted: large and huge requests fail instead of escaping - assert!(mi::heap_malloc(heap, 10 * MIB).is_null()); - assert!(mi::heap_malloc(heap, 40 * MIB).is_null()); - - for p in pinned { - mi::free(p); - } - // blocks returned: the arena serves the bound heap again - let p = mi::heap_malloc(heap, 10 * MIB); - assert!(!p.is_null() && mi::is_in_heap_region(p)); - mi::free(p); - mi::heap_delete(heap); -} diff --git a/vendor/rimalloc/tests/secure.rs b/vendor/rimalloc/tests/secure.rs deleted file mode 100644 index 27015f92..00000000 --- a/vendor/rimalloc/tests/secure.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Tests for the `secure` feature (encoded free lists, double-free detection). -#![cfg(feature = "secure")] - -use rimalloc as mi; - -#[test] -fn alloc_free_roundtrip_encoded() { - // basic correctness with encoding active across many size classes - let mut ptrs = Vec::new(); - for i in 0..2000usize { - let size = (i % 1500) + 1; - let p = mi::malloc(size); - assert!(!p.is_null()); - unsafe { std::ptr::write_bytes(p, 0x7e, size) }; - ptrs.push((p, size)); - } - for (p, size) in ptrs { - unsafe { - for j in 0..size { - assert_eq!(*p.add(j), 0x7e); - } - } - mi::free(p); - } -} - -#[test] -fn double_free_detected() { - // A double free must be detected (the block's first word decodes to a - // valid free-list link) and ignored rather than corrupting the list. - let p = mi::malloc(64); - let q = mi::malloc(64); // keep the page alive and non-empty - mi::free(p); - mi::free(p); // double free: caught by the secure check - // the page free list stays sound: we can keep allocating - let r = mi::malloc(64); - assert!(!r.is_null()); - let s = mi::malloc(64); - assert!(!s.is_null()); - assert_ne!(r, s); - mi::free(q); - mi::free(r); - mi::free(s); -} diff --git a/vendor/rimalloc/tests/target_segments.rs b/vendor/rimalloc/tests/target_segments.rs deleted file mode 100644 index 158828a6..00000000 --- a/vendor/rimalloc/tests/target_segments.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! `MIMALLOC_TARGET_SEGMENTS_PER_THREAD` makes a thread shed segments past -//! its target so other threads can reclaim them (C's -//! `mi_segments_try_abandon` / `_mi_page_force_abandon`). Own-process test: -//! the option latches on first allocator use. - -use std::sync::mpsc; - -#[test] -fn target_segments_sheds_and_reuses() { - // SAFETY: single test in this binary; no concurrent env access. - unsafe { - std::env::set_var("MIMALLOC_TARGET_SEGMENTS_PER_THREAD", "2"); - std::env::set_var("MIMALLOC_PURGE_DELAY", "0"); - } - - // Build a working set large enough to need several segments, keeping - // pages full (so they land in the full bin, the abandon candidate set), - // then keep allocating: the target trigger must abandon excess segments - // rather than growing unboundedly. We assert it stays alive and the - // freed memory round-trips through abandonment. - // Miri interprets every allocation, so scale the working set down there - // (the abandon/reclaim code paths still run, just over fewer blocks). - let (n, keep, threads_n) = if cfg!(miri) { - (200usize, 180usize, 400usize) - } else { - (4000, 3900, 8000) - }; - let mut live: Vec<*mut u8> = Vec::new(); - for round in 0..8 { - for _ in 0..n { - let p = rimalloc::malloc(4096); - assert!(!p.is_null()); - unsafe { p.write_bytes(round as u8, 64) }; - live.push(p); - } - // Free most, retain a few per round to keep churn realistic. - for p in live.drain(..keep) { - rimalloc::free(p); - } - rimalloc::collect(false); - } - for p in live.drain(..) { - rimalloc::free(p); - } - - // A second thread must be able to reclaim what the first abandoned. - let (tx, rx) = mpsc::channel::(); - let h = std::thread::spawn(move || { - let mut ps: Vec<*mut u8> = (0..threads_n).map(|_| rimalloc::malloc(4096)).collect(); - for &p in &ps { - assert!(!p.is_null()); - } - tx.send(ps.len()).unwrap(); - for p in ps.drain(..) { - rimalloc::free(p); - } - }); - assert_eq!(rx.recv().unwrap(), threads_n); - h.join().unwrap(); - rimalloc::collect(true); -} From b4053de138361d4c3d8d00fd057413e89e948358 Mon Sep 17 00:00:00 2001 From: "Adam D. Humphrey" Date: Tue, 14 Jul 2026 02:19:11 -0500 Subject: [PATCH 13/24] Highkirk --- crates/flowstate-flow/examples/dump_flow.rs | 35 ++ .../examples/ergonomics_fixture.rs | 110 ++++++ crates/flowstate-flow/src/collaboration.rs | 55 +++ crates/flowstate-flow/src/document.rs | 298 ++++++++++---- crates/flowstate-flow/src/lib.rs | 6 +- crates/flowstate-flow/src/operations.rs | 374 ++++++++++++++++-- crates/flowstate/src/flow/editor.rs | 291 ++++++++++++-- crates/flowstate/src/flow/editor/drag_drop.rs | 236 +++++++---- crates/flowstate/src/flow/editor/telemetry.rs | 216 ++++++++++ crates/flowstate/src/main.rs | 10 + ...rrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 | Bin 0 -> 13013 bytes ...rrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 | Bin 0 -> 7751 bytes flow-ergonomics-guide.md | 117 ++++++ 13 files changed, 1527 insertions(+), 221 deletions(-) create mode 100644 crates/flowstate-flow/examples/dump_flow.rs create mode 100644 crates/flowstate-flow/examples/ergonomics_fixture.rs create mode 100644 crates/flowstate-flow/src/collaboration.rs create mode 100644 crates/flowstate/src/flow/editor/telemetry.rs create mode 100644 flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 create mode 100644 flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 create mode 100644 flow-ergonomics-guide.md diff --git a/crates/flowstate-flow/examples/dump_flow.rs b/crates/flowstate-flow/examples/dump_flow.rs new file mode 100644 index 00000000..0376ad68 --- /dev/null +++ b/crates/flowstate-flow/examples/dump_flow.rs @@ -0,0 +1,35 @@ +//! Dumps a `.fl0` flow's topology and full cell text (including any notes typed into cells). +//! +//! Run: `cargo run -p flowstate-flow --example dump_flow -- ` + +use flowstate_flow::load_flow_document; + +fn main() { + let path = std::env::args().nth(1).unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); + let document = load_flow_document(&path).expect("load flow"); + let projection = document.projection(); + + for sheet in &projection.sheets { + let definition = projection.format.sheet_type(sheet.sheet_type_id); + let column_name = |id| definition.and_then(|d: &flowstate_flow::SheetTypeDefinition| d.columns.iter().find(|c| c.id == id).map(|c| c.label.clone())); + let label = |id| { + sheet + .cells + .iter() + .find(|c| c.id == id) + .and_then(|c| c.summary_text().ok()) + .map(|t| t.lines().next().unwrap_or_default().trim().to_string()) + .unwrap_or_default() + }; + println!("\n===== SHEET: {} ({} cells) =====", sheet.name, sheet.cells.len()); + for (index, cell) in sheet.cells.iter().enumerate() { + let col = column_name(cell.column_id).unwrap_or_else(|| "?".into()); + let parent = cell.parent_id.map(label).unwrap_or_else(|| "—".into()); + let text = cell.document().map(|d| d.text.to_string()).unwrap_or_default(); + println!("[{index:2}] col={col:<6} parent={parent}"); + for line in text.lines() { + println!(" | {line}"); + } + } + } +} diff --git a/crates/flowstate-flow/examples/ergonomics_fixture.rs b/crates/flowstate-flow/examples/ergonomics_fixture.rs new file mode 100644 index 00000000..63a6f58b --- /dev/null +++ b/crates/flowstate-flow/examples/ergonomics_fixture.rs @@ -0,0 +1,110 @@ +//! Generates a labeled `.fl0` flow document for drag-and-drop ergonomics testing. +//! +//! Run: `cargo run -p flowstate-flow --example ergonomics_fixture -- [out.fl0]` +//! +//! Every cell's tag text is a unique, stable label (A1, B1, …) that shows on the board and in the +//! telemetry log, so the instruction guide and `FLOWSTATE_DRAG_LOG` output line up by name. The +//! topology deliberately spans the move types the guide exercises: sibling runs, deep parent→child +//! chains, orphans mid-column, and cells of varied height. + +use flowstate_document::{ + Document, InputParagraph, InputRun, PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, ParagraphStyle, RunStyles, SEMANTIC_CITE, + document_from_input, flowstate_document_theme, +}; +use flowstate_flow::{CellId, FlowDocument, SheetId, save_flow_document}; + +#[derive(Clone, Copy)] +enum Tier { + Short, + Medium, + Tall, + Card, +} + +fn run(text: &str) -> InputRun { + InputRun { + text: text.into(), + styles: RunStyles::default(), + } +} + +fn cite(text: &str) -> InputRun { + let mut run = run(text); + run.styles.semantic = SEMANTIC_CITE; + run +} + +fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { + InputParagraph { style, runs } +} + +fn content(label: &str, tier: Tier) -> Document { + let mut paragraphs = vec![paragraph(PARAGRAPH_TAG, vec![run(label)])]; + match tier { + Tier::Short => {}, + Tier::Medium => paragraphs.push(paragraph(PARAGRAPH_UNDERTAG, vec![run(&format!("underview supporting {label}"))])), + Tier::Tall => { + paragraphs.push(paragraph(PARAGRAPH_UNDERTAG, vec![run(&format!("underview supporting {label}"))])); + paragraphs.push(paragraph( + PARAGRAPH_ANALYTIC, + vec![run(&format!("analytic {label}: first supporting line, long enough to wrap and add real height"))], + )); + paragraphs.push(paragraph(PARAGRAPH_ANALYTIC, vec![run(&format!("analytic {label}: second supporting line"))])); + }, + Tier::Card => paragraphs.push(paragraph( + ParagraphStyle::Normal, + vec![cite(&format!("{label} — Author 2024")), run(" full card body that stays hidden in the summary projection")], + )), + } + document_from_input(flowstate_document_theme(), paragraphs) +} + +fn add(document: &mut FlowDocument, sheet: SheetId, column: usize, parent: Option, label: &str, tier: Tier) -> CellId { + let id = document.add_plain_cell(sheet, column, parent, None).expect("add cell"); + document.replace_cell_document(sheet, id, &content(label, tier)).expect("set cell content"); + id +} + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); + let mut document = FlowDocument::new(); + let affirmative = document.projection().format.sheet_types[0].id; // 1AC 1NC 2AC Block 1AR 2NR 2AR + let negative = document.projection().format.sheet_types[1].id; // 1NC 2AC Block 1AR 2NR 2AR + + // ---- Sheet 1: "Ergonomics" (Affirmative, 7 columns) ---- + let sheet = document.create_sheet("Ergonomics", affirmative).expect("create sheet"); + + // A1 with two responses B1/B2, and a deep chain B1 -> C1 -> D1 -> E1 -> F1. + let a1 = add(&mut document, sheet, 0, None, "A1", Tier::Medium); + let b1 = add(&mut document, sheet, 1, Some(a1), "B1", Tier::Card); + add(&mut document, sheet, 1, Some(a1), "B2", Tier::Short); + let c1 = add(&mut document, sheet, 2, Some(b1), "C1", Tier::Medium); + let d1 = add(&mut document, sheet, 3, Some(c1), "D1", Tier::Short); + let e1 = add(&mut document, sheet, 4, Some(d1), "E1", Tier::Short); + add(&mut document, sheet, 5, Some(e1), "F1", Tier::Medium); + + // A2 with a response B3 that has two children C2/C3. + let a2 = add(&mut document, sheet, 0, None, "A2", Tier::Tall); + let b3 = add(&mut document, sheet, 1, Some(a2), "B3", Tier::Medium); + add(&mut document, sheet, 2, Some(b3), "C2", Tier::Short); + add(&mut document, sheet, 2, Some(b3), "C3", Tier::Tall); + + // A3 leaf root, plus its response B5. + let a3 = add(&mut document, sheet, 0, None, "A3", Tier::Short); + add(&mut document, sheet, 1, Some(a3), "B5", Tier::Short); + + // Orphans (no parent) sitting in interior columns. + add(&mut document, sheet, 1, None, "B4", Tier::Short); + add(&mut document, sheet, 2, None, "C4", Tier::Short); + + // ---- Sheet 2: "Scratch" (Negative, 6 columns) ---- + let scratch = document.create_sheet("Scratch", negative).expect("create sheet"); + let s1 = add(&mut document, scratch, 0, None, "S1", Tier::Short); + add(&mut document, scratch, 1, Some(s1), "S2", Tier::Short); + add(&mut document, scratch, 0, None, "S3", Tier::Medium); + + save_flow_document(&out, &document).expect("save fixture"); + let projection = document.projection(); + let cells: usize = projection.sheets.iter().map(|sheet| sheet.cells.len()).sum(); + println!("wrote {out} — {} sheets, {cells} cells", projection.sheets.len()); +} diff --git a/crates/flowstate-flow/src/collaboration.rs b/crates/flowstate-flow/src/collaboration.rs new file mode 100644 index 00000000..f740322c --- /dev/null +++ b/crates/flowstate-flow/src/collaboration.rs @@ -0,0 +1,55 @@ +use std::fmt; + +use loro::VersionVector; + +use crate::FlowProjection; + +pub type FlowFrontier = Vec; +pub type FlowUpdateBytes = Vec; +pub type FlowTransactionId = u128; + +#[derive(Clone, Debug)] +pub struct FlowProjectionSnapshot { + pub projection: FlowProjection, + pub frontier: FlowFrontier, + pub version_vector: VersionVector, +} + +#[derive(Clone, Debug)] +pub enum FlowRuntimeEvent { + LocalUpdate { + bytes: FlowUpdateBytes, + frontier: FlowFrontier, + version_vector: VersionVector, + }, + RemoteUpdateApplied { + bytes_len: usize, + frontier: FlowFrontier, + version_vector: VersionVector, + }, + ProjectionUpdated { + snapshot: Box, + }, +} + +#[derive(Clone, Debug)] +pub struct FlowCommitResult { + pub transaction_id: FlowTransactionId, + pub base_frontier: FlowFrontier, + pub new_frontier: FlowFrontier, + pub events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StaleFlowProjectionError { + pub expected: FlowFrontier, + pub actual: FlowFrontier, +} + +impl fmt::Display for StaleFlowProjectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("flow collaboration transaction was based on a stale projection frontier") + } +} + +impl std::error::Error for StaleFlowProjectionError {} diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index b3718e4e..08fe0413 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -5,6 +5,10 @@ use loro::{ExportMode, LoroDoc, LoroValue, UndoManager, VersionVector, ValueOrCo use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::{ + FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, +}; + pub type FormatId = Uuid; pub type SheetTypeId = Uuid; pub type SheetId = Uuid; @@ -15,7 +19,6 @@ pub type StrokeId = Uuid; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct AnnotationOriginator(pub String); -const PROJECTION_KEY: &str = "projection"; const FORMAT_KEY: &str = "immutable-format"; const ANNOTATIONS_MAP: &str = "flow-annotations"; const SHEETS_MAP: &str = "flow-sheets"; @@ -361,9 +364,7 @@ impl FlowDocument { pub fn from_projection(projection: FlowProjection) -> anyhow::Result { projection.validate()?; let loro = LoroDoc::new(); - let root = loro.get_map("flow"); - root.insert(FORMAT_KEY, postcard::to_allocvec(&projection.format)?)?; - root.insert(PROJECTION_KEY, postcard::to_allocvec(&projection)?)?; + loro.get_map("flow").insert(FORMAT_KEY, postcard::to_allocvec(&projection.format)?)?; let annotations = loro.get_map(ANNOTATIONS_MAP); for stroke in projection.sheets.iter().flat_map(|sheet| &sheet.annotations) { annotations.insert(&stroke.id.to_string(), postcard::to_allocvec(stroke)?)?; @@ -381,17 +382,7 @@ impl FlowDocument { pub fn from_snapshot(snapshot: &[u8]) -> anyhow::Result { let loro = LoroDoc::new(); loro.import(snapshot)?; - let value = loro.get_map("flow").get(PROJECTION_KEY).context("Loro snapshot is missing flow projection")?; - let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { - bail!("Loro flow projection has invalid type"); - }; - let mut projection: FlowProjection = postcard::from_bytes(&bytes)?; - let immutable_format = read_immutable_format(&loro)?; - if projection.format != immutable_format { - bail!("persisted flow format definition was mutated"); - } - merge_entity_records_from_loro(&mut projection, &loro)?; - projection.validate()?; + let projection = load_projection(&loro)?; let undo_manager = UndoManager::new(&loro); Ok(Self { loro, @@ -404,39 +395,105 @@ impl FlowDocument { Ok(self.loro.export(ExportMode::Snapshot)?) } - /// suggestion: publish these opaque Loro update bytes through the symmetric - /// collaboration transport selected by the document-collaboration branch. - pub fn updates_since(&self, version: &VersionVector) -> anyhow::Result> { + pub fn projection_snapshot(&self) -> FlowProjectionSnapshot { + FlowProjectionSnapshot { + projection: self.projection.clone(), + frontier: self.frontier(), + version_vector: self.version_vector(), + } + } + + pub fn frontier(&self) -> FlowFrontier { + self.loro.state_frontiers().encode() + } + + pub fn export_updates_for(&self, version: &VersionVector) -> anyhow::Result { Ok(self.loro.export(ExportMode::updates(version))?) } + pub fn updates_since(&self, version: &VersionVector) -> anyhow::Result { + self.export_updates_for(version) + } + pub fn version_vector(&self) -> VersionVector { self.loro.oplog_vv() } - /// suggestion: call this for remote update bytes, then notify every UI - /// projection consumer. Flow peers are symmetric and every peer writes. - pub fn import_updates(&mut self, bytes: &[u8]) -> anyhow::Result<()> { + pub fn import_remote_update(&mut self, bytes: &[u8]) -> anyhow::Result> { self.loro.import(bytes)?; - self.reload_projection() + self.reload_projection()?; + Ok(vec![ + FlowRuntimeEvent::RemoteUpdateApplied { + bytes_len: bytes.len(), + frontier: self.frontier(), + version_vector: self.version_vector(), + }, + FlowRuntimeEvent::ProjectionUpdated { + snapshot: Box::new(self.projection_snapshot()), + }, + ]) } - pub fn update(&mut self, change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>) -> anyhow::Result<()> { - let before = self.projection.clone(); - change(&mut self.projection)?; - if let Err(error) = self.projection.validate() { - self.projection = before; - return Err(error); + pub fn import_updates(&mut self, bytes: &[u8]) -> anyhow::Result<()> { + self.import_remote_update(bytes).map(|_| ()) + } + + pub fn apply_projection_transaction( + &mut self, + transaction_id: FlowTransactionId, + base_frontier: &[u8], + change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>, + ) -> anyhow::Result { + let actual_frontier = self.frontier(); + if !base_frontier.is_empty() && base_frontier != actual_frontier.as_slice() { + return Err(StaleFlowProjectionError { + expected: base_frontier.to_vec(), + actual: actual_frontier, + } + .into()); } - if self.projection.format != read_immutable_format(&self.loro)? { - self.projection = before; + + let base_frontier = actual_frontier; + let before_vv = self.version_vector(); + self.update(change)?; + let update = self.export_updates_for(&before_vv)?; + let new_frontier = self.frontier(); + let version_vector = self.version_vector(); + Ok(FlowCommitResult { + transaction_id, + base_frontier, + new_frontier: new_frontier.clone(), + events: vec![ + FlowRuntimeEvent::LocalUpdate { + bytes: update, + frontier: new_frontier, + version_vector: version_vector.clone(), + }, + FlowRuntimeEvent::ProjectionUpdated { + snapshot: Box::new(FlowProjectionSnapshot { + projection: self.projection.clone(), + frontier: self.frontier(), + version_vector, + }), + }, + ], + }) + } + + pub fn update(&mut self, change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>) -> anyhow::Result<()> { + // Mutate a draft clone so a failing `change` or a rejected validation leaves `self` untouched. + let mut draft = self.projection.clone(); + change(&mut draft)?; + draft.validate()?; + if draft.format != read_immutable_format(&self.loro)? { bail!("flow format definitions are immutable"); } - sync_entity_delta(&self.loro, &before, &self.projection)?; - if let Err(error) = self.commit_projection() { - self.projection = before; - return Err(error); - } + // Serialize the whole delta up front. Only once every fallible step has succeeded do we touch the + // Loro doc, so a rejected update can never leave half-written, uncommitted state behind. + let delta = compute_entity_delta(&self.projection, &draft)?; + apply_entity_delta(&self.loro, delta)?; + self.loro.commit(); + self.projection = draft; Ok(()) } @@ -464,36 +521,26 @@ impl FlowDocument { Ok(changed) } - fn commit_projection(&mut self) -> anyhow::Result<()> { - if self.projection.format != read_immutable_format(&self.loro)? { - bail!("flow format definitions are immutable"); - } - let bytes = postcard::to_allocvec(&self.projection)?; - self.loro.get_map("flow").insert(PROJECTION_KEY, bytes)?; - self.loro.commit(); - Ok(()) - } - fn reload_projection(&mut self) -> anyhow::Result<()> { - let value = self - .loro - .get_map("flow") - .get(PROJECTION_KEY) - .context("Loro snapshot is missing flow projection")?; - let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { - bail!("Loro flow projection has invalid type"); - }; - let mut projection: FlowProjection = postcard::from_bytes(&bytes)?; - if projection.format != read_immutable_format(&self.loro)? { - bail!("persisted flow format definition was mutated"); - } - merge_entity_records_from_loro(&mut projection, &self.loro)?; - projection.validate()?; - self.projection = projection; + self.projection = load_projection(&self.loro)?; Ok(()) } } +/// Rebuild the projection from the immutable format plus the granular per-entity records. These +/// records are the source of truth for sheets, cells, and annotations — there is no whole-projection +/// blob to read back. +fn load_projection(loro: &LoroDoc) -> anyhow::Result { + let format = read_immutable_format(loro)?; + let mut projection = FlowProjection { + format, + sheets: Vec::new(), + }; + merge_entity_records_from_loro(&mut projection, loro)?; + projection.validate()?; + Ok(projection) +} + fn read_immutable_format(loro: &LoroDoc) -> anyhow::Result { let value = loro.get_map("flow").get(FORMAT_KEY).context("Loro snapshot is missing immutable format definition")?; let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { @@ -502,43 +549,37 @@ fn read_immutable_format(loro: &LoroDoc) -> anyhow::Result { Ok(postcard::from_bytes(&bytes)?) } -fn sync_entity_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { - sync_sheet_delta(loro, before, after)?; - sync_cell_delta(loro, before, after)?; - sync_annotation_delta(loro, before, after) -} - -fn sync_sheet_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { - let before = sheet_records(before); - let after = sheet_records(after); - sync_record_map(loro, SHEETS_MAP, &before, &after) -} +/// A single pending change to a Loro entity map: `Some(bytes)` inserts/updates a key, `None` deletes it. +type EntityOp = (&'static str, String, Option>); -fn sync_cell_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { - let before = cell_records(before); - let after = cell_records(after); - sync_record_map(loro, CELLS_MAP, &before, &after) +/// Serialize the full sheet/cell/annotation delta between two projections *without* touching the Loro +/// doc, so serialization failures can be surfaced before any state is mutated (see [`FlowDocument::update`]). +fn compute_entity_delta(before: &FlowProjection, after: &FlowProjection) -> anyhow::Result> { + let mut ops = Vec::new(); + push_record_ops(&mut ops, SHEETS_MAP, &sheet_records(before), &sheet_records(after))?; + push_record_ops(&mut ops, CELLS_MAP, &cell_records(before), &cell_records(after))?; + push_annotation_ops(&mut ops, before, after)?; + Ok(ops) } -fn sync_record_map( - loro: &LoroDoc, - map_name: &str, +fn push_record_ops( + ops: &mut Vec, + map_name: &'static str, before: &HashMap, after: &HashMap, ) -> anyhow::Result<()> { - let map = loro.get_map(map_name); for id in before.keys().filter(|id| !after.contains_key(id)) { - map.delete(&id.to_string())?; + ops.push((map_name, id.to_string(), None)); } for (id, record) in after { if before.get(id) != Some(record) { - map.insert(&id.to_string(), postcard::to_allocvec(record)?)?; + ops.push((map_name, id.to_string(), Some(postcard::to_allocvec(record)?))); } } Ok(()) } -fn sync_annotation_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { +fn push_annotation_ops(ops: &mut Vec, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { let before: HashMap<_, _> = before .sheets .iter() @@ -549,13 +590,25 @@ fn sync_annotation_delta(loro: &LoroDoc, before: &FlowProjection, after: &FlowPr .iter() .flat_map(|sheet| sheet.annotations.iter().map(|stroke| (stroke.id, stroke))) .collect(); - let map = loro.get_map(ANNOTATIONS_MAP); for id in before.keys().filter(|id| !after.contains_key(id)) { - map.delete(&id.to_string())?; + ops.push((ANNOTATIONS_MAP, id.to_string(), None)); } for (id, stroke) in &after { if before.get(id).is_none_or(|existing| *existing != *stroke) { - map.insert(&id.to_string(), postcard::to_allocvec(*stroke)?)?; + ops.push((ANNOTATIONS_MAP, id.to_string(), Some(postcard::to_allocvec(*stroke)?))); + } + } + Ok(()) +} + +/// Apply a pre-serialized delta to the Loro doc. This is the only step in [`FlowDocument::update`] that +/// mutates Loro state; it runs after every fallible step has already succeeded. +fn apply_entity_delta(loro: &LoroDoc, ops: Vec) -> anyhow::Result<()> { + for (map_name, key, value) in ops { + let map = loro.get_map(map_name); + match value { + Some(bytes) => map.insert(key.as_str(), bytes)?, + None => map.delete(key.as_str())?, } } Ok(()) @@ -771,4 +824,79 @@ mod tests { .is_err() ); } + + #[test] + fn collaboration_transaction_returns_update_and_projection_events() { + let mut document = FlowDocument::new(); + let base_frontier = document.frontier(); + let sheet_type = document.projection().format.sheet_types[0].id; + let sheet_id = Uuid::new_v4(); + + let commit = document + .apply_projection_transaction(7, &base_frontier, |projection| { + projection.sheets.push(Sheet { + id: sheet_id, + name: "Shared".into(), + sheet_type_id: sheet_type, + cells: Vec::new(), + annotations: Vec::new(), + }); + Ok(()) + }) + .unwrap(); + + assert_eq!(commit.transaction_id, 7); + assert_eq!(commit.base_frontier, base_frontier); + assert_eq!(commit.new_frontier, document.frontier()); + assert_eq!(commit.events.len(), 2); + assert!(matches!(commit.events[0], FlowRuntimeEvent::LocalUpdate { .. })); + assert!(matches!(commit.events[1], FlowRuntimeEvent::ProjectionUpdated { .. })); + } + + #[test] + fn collaboration_transaction_rejects_stale_frontier() { + let mut document = FlowDocument::new(); + let sheet_type = document.projection().format.sheet_types[0].id; + document.create_sheet("Case", sheet_type).unwrap(); + let stale_frontier = document.frontier(); + document + .update(|projection| { + projection.sheets[0].name = "Changed".into(); + Ok(()) + }) + .unwrap(); + + let error = document + .apply_projection_transaction(8, &stale_frontier, |projection| { + projection.sheets[0].name = "Rejected".into(); + Ok(()) + }) + .unwrap_err(); + + assert!(error.downcast_ref::().is_some()); + assert_eq!(document.projection().sheets[0].name, "Changed"); + } + + #[test] + fn collaboration_remote_update_reloads_projection() { + let mut source = FlowDocument::new(); + let sheet_type = source.projection().format.sheet_types[0].id; + source.create_sheet("Case", sheet_type).unwrap(); + let mut target = FlowDocument::from_snapshot(&source.snapshot().unwrap()).unwrap(); + let target_vv = target.version_vector(); + source + .update(|projection| { + projection.sheets[0].name = "Remote".into(); + Ok(()) + }) + .unwrap(); + let update = source.export_updates_for(&target_vv).unwrap(); + + let events = target.import_remote_update(&update).unwrap(); + + assert_eq!(target.projection().sheets[0].name, "Remote"); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], FlowRuntimeEvent::RemoteUpdateApplied { .. })); + assert!(matches!(events[1], FlowRuntimeEvent::ProjectionUpdated { .. })); + } } diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index 7e95d6c3..a75b952d 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -1,11 +1,15 @@ +mod collaboration; mod document; mod operations; mod persistence; +pub use collaboration::{ + FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, +}; pub use document::{ AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, Cell, CellId, ColumnDefinition, ColumnId, FlowDocument, FlowFormat, FlowProjection, FormatId, Sheet, SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, }; +pub use operations::{FlowDropIntent, RelativePosition}; pub use persistence::{decode as decode_flow_document, encode as encode_flow_document, load_flow_document, save_flow_document}; -pub use operations::RelativePosition; pub use loro::VersionVector; diff --git a/crates/flowstate-flow/src/operations.rs b/crates/flowstate-flow/src/operations.rs index 6a64046d..5e5e1a44 100644 --- a/crates/flowstate-flow/src/operations.rs +++ b/crates/flowstate-flow/src/operations.rs @@ -1,7 +1,9 @@ +use std::collections::{HashMap, HashSet}; + use anyhow::{Context as _, bail}; use uuid::Uuid; -use crate::{AnnotationOriginator, AnnotationStroke, Cell, CellId, FlowDocument, Sheet, SheetId, SheetTypeId}; +use crate::{AnnotationOriginator, AnnotationStroke, Cell, CellId, FlowDocument, FlowProjection, Sheet, SheetId, SheetTypeId}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RelativePosition { @@ -9,6 +11,18 @@ pub enum RelativePosition { After, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FlowDropIntent { + BeforeSibling(CellId), + AfterSibling(CellId), + FirstChildOf(CellId), + LastChildOf(CellId), + RootInColumn { + column_index: usize, + insertion_index: usize, + }, +} + impl FlowDocument { pub fn child_append_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; @@ -282,25 +296,61 @@ impl FlowDocument { target_index: usize, new_parent: Option, ) -> anyhow::Result<()> { + let intent = new_parent.map_or( + FlowDropIntent::RootInColumn { + column_index: target_column, + insertion_index: target_index, + }, + FlowDropIntent::LastChildOf, + ); + self.move_cell_subtree(sheet_id, cell_id, intent) + } + + pub fn move_cell_subtree(&mut self, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { self.update(|projection| { + let column_ids = sheet_column_ids(projection, sheet_id)?; let sheet = projection.sheets.iter_mut().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; - let definition = projection.format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; - let column_id = definition.columns.get(target_column).context("column index out of range")?.id; - let source = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; - let mut cell = sheet.cells.remove(source); - cell.column_id = column_id; - cell.parent_id = new_parent; - for child in &mut sheet.cells { - if child.parent_id == Some(cell_id) { - child.parent_id = None; - } - } - let target_index = target_index.saturating_sub(usize::from(source < target_index)); - sheet.cells.insert(target_index.min(sheet.cells.len()), cell); - Ok(()) + apply_move_subtree(sheet, &column_ids, cell_id, intent) }) } + /// Read-only preview of [`Self::move_cell_subtree`]: returns a clone of the sheet with the drag + /// already applied, or `None` if the move is invalid (self/descendant drop, or a descendant would + /// fall outside the sheet columns). Nothing is committed, so this is safe to call every drag frame. + pub fn preview_move_cell_subtree(&self, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> Option { + let projection = self.projection(); + let column_ids = sheet_column_ids(projection, sheet_id).ok()?; + let mut sheet = projection.sheets.iter().find(|sheet| sheet.id == sheet_id)?.clone(); + apply_move_subtree(&mut sheet, &column_ids, cell_id, intent).ok()?; + // Only report a landing the real (fully validated) move would accept, so the drag preview never + // shows the cell dropping somewhere the commit will silently reject. + sheet_topology_ok(&sheet, &column_ids).then_some(sheet) + } + + /// Read-only preview of the sheet with the dragged cell and its whole subtree lifted out, so the + /// board can reflow as if it were gone while a drag is in flight and no valid target is hovered yet. + pub fn preview_without_subtree(&self, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = self.projection().sheets.iter().find(|sheet| sheet.id == sheet_id)?; + let subtree: HashSet = subtree_cell_ids(sheet, cell_id).into_iter().collect(); + if subtree.is_empty() { + return None; + } + let mut sheet = sheet.clone(); + sheet.cells.retain(|cell| !subtree.contains(&cell.id)); + Some(sheet) + } + + /// The dragged cell plus every descendant, in sheet order. Empty if the cell is unknown. + pub fn subtree_cell_ids_for(&self, sheet_id: SheetId, cell_id: CellId) -> Vec { + self + .projection() + .sheets + .iter() + .find(|sheet| sheet.id == sheet_id) + .map(|sheet| subtree_cell_ids(sheet, cell_id)) + .unwrap_or_default() + } + pub fn add_annotation(&mut self, sheet_id: SheetId, stroke: AnnotationStroke) -> anyhow::Result<()> { self.update(|projection| { if stroke.sheet_id != sheet_id { @@ -358,6 +408,185 @@ fn is_descendant_of(sheet: &Sheet, cell_id: CellId, ancestor_id: CellId) -> bool false } +fn subtree_cell_ids(sheet: &Sheet, root_id: CellId) -> Vec { + sheet + .cells + .iter() + .filter(|cell| cell.id == root_id || is_descendant_of(sheet, cell.id, root_id)) + .map(|cell| cell.id) + .collect() +} + +/// Cheap structural check of the two move-sensitive invariants — parent/child column adjacency and +/// contiguous sibling runs per column — without deserializing any cell documents. A move never +/// touches cell content, so if the sheet was valid before, this is exactly the set of failures a move +/// can introduce, matching what [`FlowProjection::validate`] would reject. +fn sheet_topology_ok(sheet: &Sheet, column_ids: &[Uuid]) -> bool { + let level = |column: Uuid| column_ids.iter().position(|candidate| *candidate == column); + let cells: HashMap = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); + for cell in &sheet.cells { + let Some(cell_level) = level(cell.column_id) else { + return false; + }; + if let Some(parent_id) = cell.parent_id { + let Some(parent) = cells.get(&parent_id) else { + return false; + }; + let Some(parent_level) = level(parent.column_id) else { + return false; + }; + if cell_level != parent_level + 1 { + return false; + } + } + } + for &column in column_ids { + let mut completed_parents = HashSet::new(); + let mut current_parent = None; + for cell in sheet.cells.iter().filter(|cell| cell.column_id == column) { + if cell.parent_id != current_parent { + if let Some(parent) = current_parent { + completed_parents.insert(parent); + } + if cell.parent_id.is_some_and(|parent| completed_parents.contains(&parent)) { + return false; + } + current_parent = cell.parent_id; + } + } + } + true +} + +fn sheet_column_ids(projection: &FlowProjection, sheet_id: SheetId) -> anyhow::Result> { + let sheet = projection.sheets.iter().find(|sheet| sheet.id == sheet_id).context("unknown sheet")?; + let definition = projection.format.sheet_type(sheet.sheet_type_id).context("unknown sheet type")?; + Ok(definition.columns.iter().map(|column| column.id).collect()) +} + +/// Move `cell_id` (and its subtree) within `sheet` according to `intent`. Pure over the sheet so the +/// same algorithm backs both the committing [`FlowDocument::move_cell_subtree`] and the read-only +/// drag previews. +fn apply_move_subtree(sheet: &mut Sheet, column_ids: &[Uuid], cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { + let source = sheet.cells.iter().position(|cell| cell.id == cell_id).context("unknown cell")?; + let source_level = column_ids + .iter() + .position(|column| *column == sheet.cells[source].column_id) + .context("source cell references unknown column")?; + + let (target_column, target_index, new_parent) = resolve_drop_intent(sheet, column_ids, cell_id, intent)?; + let level_delta = target_column as isize - source_level as isize; + let subtree_ids = subtree_cell_ids(sheet, cell_id); + if let Some(parent) = new_parent + && subtree_ids.contains(&parent) + { + bail!("cannot move a cell into itself or one of its descendants"); + } + let removed_before_target = sheet + .cells + .iter() + .take(target_index.min(sheet.cells.len())) + .filter(|cell| subtree_ids.contains(&cell.id)) + .count(); + + let mut subtree = Vec::new(); + let mut remaining = Vec::with_capacity(sheet.cells.len()); + for cell in sheet.cells.drain(..) { + if subtree_ids.contains(&cell.id) { + subtree.push(cell); + } else { + remaining.push(cell); + } + } + let insertion_index = target_index.saturating_sub(removed_before_target).min(remaining.len()); + + for cell in &mut subtree { + let old_level = column_ids + .iter() + .position(|column| *column == cell.column_id) + .context("subtree cell references unknown column")?; + let new_level = old_level + .checked_add_signed(level_delta) + .filter(|level| *level < column_ids.len()) + .context("moving this subtree would place a descendant outside the sheet columns")?; + cell.column_id = column_ids[new_level]; + if cell.id == cell_id { + cell.parent_id = new_parent; + } + } + remaining.splice(insertion_index..insertion_index, subtree); + sheet.cells = remaining; + Ok(()) +} + +fn resolve_drop_intent( + sheet: &Sheet, + column_ids: &[uuid::Uuid], + dragged: CellId, + intent: FlowDropIntent, +) -> anyhow::Result<(usize, usize, Option)> { + match intent { + FlowDropIntent::BeforeSibling(target) | FlowDropIntent::AfterSibling(target) => { + if dragged == target { + bail!("cannot drop a cell relative to itself"); + } + if is_descendant_of(sheet, target, dragged) { + bail!("cannot drop a cell relative to one of its descendants"); + } + let target_index = sheet.cells.iter().position(|cell| cell.id == target).context("unknown target cell")?; + let target_cell = &sheet.cells[target_index]; + let column_index = column_ids + .iter() + .position(|column| *column == target_cell.column_id) + .context("target cell references unknown column")?; + let insertion_index = match intent { + FlowDropIntent::BeforeSibling(_) => target_index, + FlowDropIntent::AfterSibling(_) => target_index + 1, + FlowDropIntent::FirstChildOf(_) | FlowDropIntent::LastChildOf(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((column_index, insertion_index, target_cell.parent_id)) + }, + FlowDropIntent::FirstChildOf(parent) | FlowDropIntent::LastChildOf(parent) => { + if dragged == parent { + bail!("cannot drop a cell onto itself"); + } + if is_descendant_of(sheet, parent, dragged) { + bail!("cannot drop a cell onto one of its descendants"); + } + let parent_index = sheet.cells.iter().position(|cell| cell.id == parent).context("unknown parent cell")?; + let parent_cell = &sheet.cells[parent_index]; + let parent_column = column_ids + .iter() + .position(|column| *column == parent_cell.column_id) + .context("parent cell references unknown column")?; + let child_column = parent_column.checked_add(1).filter(|index| *index < column_ids.len()).context("parent has no child column")?; + let insertion_index = match intent { + FlowDropIntent::FirstChildOf(_) => parent_index + 1, + FlowDropIntent::LastChildOf(_) => sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1, + FlowDropIntent::BeforeSibling(_) | FlowDropIntent::AfterSibling(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((child_column, insertion_index, Some(parent))) + }, + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => { + if column_index >= column_ids.len() { + bail!("column index out of range"); + } + Ok((column_index, insertion_index, None)) + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -449,30 +678,119 @@ mod tests { } #[test] - fn moving_parent_away_orphans_invalid_children() { + fn moving_parent_as_subtree_preserves_child_links() { let (mut document, sheet) = document_with_sheet(); let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); let child = document.add_response(sheet, parent).unwrap(); - document.move_cell(sheet, parent, 6, 0, None).unwrap(); - assert_eq!(document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().parent_id, None); + let grandchild = document.add_response(sheet, child).unwrap(); + document + .move_cell_subtree( + sheet, + parent, + FlowDropIntent::RootInColumn { + column_index: 1, + insertion_index: 0, + }, + ) + .unwrap(); + let sheet = &document.projection().sheets[0]; + let parent_cell = sheet.cells.iter().find(|cell| cell.id == parent).unwrap(); + let child_cell = sheet.cells.iter().find(|cell| cell.id == child).unwrap(); + let grandchild_cell = sheet.cells.iter().find(|cell| cell.id == grandchild).unwrap(); + let columns = &document.projection().format.sheet_types[0].columns; + assert_eq!(parent_cell.column_id, columns[1].id); + assert_eq!(parent_cell.parent_id, None); + assert_eq!(child_cell.column_id, columns[2].id); + assert_eq!(child_cell.parent_id, Some(parent)); + assert_eq!(grandchild_cell.column_id, columns[3].id); + assert_eq!(grandchild_cell.parent_id, Some(child)); } #[test] - fn moving_parent_orphans_children_and_leaves_them_in_place() { + fn moving_parent_subtree_keeps_descendants_contiguous() { let (mut document, sheet) = document_with_sheet(); + let other = document.add_plain_cell(sheet, 0, None, None).unwrap(); let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); let child = document.add_response(sheet, parent).unwrap(); let grandchild = document.add_response(sheet, child).unwrap(); - let child_column = document.projection().sheets[0].cells.iter().find(|cell| cell.id == child).unwrap().column_id; - let grandchild_column = document.projection().sheets[0].cells.iter().find(|cell| cell.id == grandchild).unwrap().column_id; - document.move_cell(sheet, parent, 1, 0, None).unwrap(); + document.move_cell_subtree(sheet, parent, FlowDropIntent::BeforeSibling(other)).unwrap(); + let sheet = &document.projection().sheets[0]; + assert_eq!( + sheet.cells.iter().map(|cell| cell.id).collect::>(), + vec![parent, child, grandchild, other] + ); + } + + #[test] + fn preview_move_matches_the_committed_move() { + let (mut document, sheet) = document_with_sheet(); + let first = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let second = document.add_plain_cell(sheet, 0, None, None).unwrap(); + + let preview = document.preview_move_cell_subtree(sheet, first, FlowDropIntent::AfterSibling(second)).unwrap(); + document.move_cell_subtree(sheet, first, FlowDropIntent::AfterSibling(second)).unwrap(); + + assert_eq!( + preview.cells.iter().map(|cell| cell.id).collect::>(), + document.projection().sheets[0].cells.iter().map(|cell| cell.id).collect::>() + ); + } + + #[test] + fn preview_without_subtree_lifts_the_whole_family() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let _child = document.add_response(sheet, parent).unwrap(); + let other = document.add_plain_cell(sheet, 0, None, None).unwrap(); + + let lifted = document.preview_without_subtree(sheet, parent).unwrap(); + + assert_eq!(lifted.cells.iter().map(|cell| cell.id).collect::>(), vec![other]); + } + + #[test] + fn preview_move_rejects_targets_that_would_split_a_sibling_run() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let _first = document.add_response(sheet, parent).unwrap(); + let second = document.add_response(sheet, parent).unwrap(); + let orphan = document.add_plain_cell(sheet, 1, None, None).unwrap(); + + // Dropping the parentless `orphan` between `parent`'s two responses would split their sibling run, + // which the committed move rejects — so the preview must decline to show a landing there. + let insertion_index = document.projection().sheets[0].cells.iter().position(|cell| cell.id == second).unwrap(); + let intent = FlowDropIntent::RootInColumn { + column_index: 1, + insertion_index, + }; + + assert!(document.preview_move_cell_subtree(sheet, orphan, intent).is_none()); + assert!(document.move_cell_subtree(sheet, orphan, intent).is_err()); + } + + #[test] + fn preview_move_onto_descendant_returns_none() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let child = document.add_response(sheet, parent).unwrap(); + + assert!(document.preview_move_cell_subtree(sheet, parent, FlowDropIntent::LastChildOf(child)).is_none()); + } + + #[test] + fn moving_cell_onto_descendant_is_rejected() { + let (mut document, sheet) = document_with_sheet(); + let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); + let child = document.add_response(sheet, parent).unwrap(); + + let error = document + .move_cell_subtree(sheet, parent, FlowDropIntent::LastChildOf(child)) + .unwrap_err(); + + assert!(error.to_string().contains("descendants")); let sheet = &document.projection().sheets[0]; - let child = sheet.cells.iter().find(|cell| cell.id == child).unwrap(); - let grandchild = sheet.cells.iter().find(|cell| cell.id == grandchild).unwrap(); - assert_eq!(child.column_id, child_column); - assert_eq!(child.parent_id, None); - assert_eq!(grandchild.column_id, grandchild_column); - assert_eq!(grandchild.parent_id, Some(child.id)); + assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![parent, child]); + assert_eq!(sheet.cells[1].parent_id, Some(parent)); } #[test] diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index b0790e0b..349284bd 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use std::collections::HashSet; -use flowstate_flow::{AnnotationOriginator, BoardPoint, CellId, FlowDocument, RelativePosition, SheetId, VersionVector}; +use flowstate_flow::{ + AnnotationOriginator, BoardPoint, CellId, FlowCommitResult, FlowDocument, FlowFrontier, FlowProjection, FlowProjectionSnapshot, + FlowDropIntent, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, RelativePosition, SheetId, VersionVector, +}; use gpui::{ AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, KeyDownEvent, KeyUpEvent, MouseMoveEvent, MouseUpEvent, Render, SharedString, Subscription, Task, Window, canvas, div, point, @@ -24,11 +27,12 @@ mod cell_editing; mod connector; mod drag_drop; mod layout; +mod telemetry; mod zoom; use annotation::paint_stroke; use connector::paint_connector_family; -use drag_drop::{CellDropDestination, FlowCellDrag, FlowCellDragPreview}; +use drag_drop::{DropEdge, FlowCellDrag, FlowCellDragPreview}; use layout::{CellMeasurement, sheet_cell_layout}; use zoom::grid_dot_metrics; @@ -69,7 +73,12 @@ pub struct FlowEditor { cell_editors: std::collections::HashMap>, cell_editor_themes: std::collections::HashMap, cell_editor_subscriptions: std::collections::HashMap, - pending_cell_drop: Option, + pending_cell_drop: Option, + dragging_cell: Option, + drag_autoscroll: Option>, + drag_autoscroll_scheduled: bool, + drag_log: Option, + drag_log_counter: u64, cell_bounds: std::collections::HashMap>, cell_measurements: std::collections::HashMap, board_scroll: ScrollHandle, @@ -101,14 +110,17 @@ impl FlowEditor { annotation_tool: AnnotationTool::None, hidden_annotation_sheets: HashSet::new(), hidden_annotation_originators: HashSet::new(), - // suggestion: replace this opaque local value with the identity source - // selected by the document-collaboration implementation. local_annotation_originator: AnnotationOriginator("local".into()), drawing_points: Vec::new(), cell_editors: std::collections::HashMap::new(), cell_editor_themes: std::collections::HashMap::new(), cell_editor_subscriptions: std::collections::HashMap::new(), pending_cell_drop: None, + dragging_cell: None, + drag_autoscroll: None, + drag_autoscroll_scheduled: false, + drag_log: None, + drag_log_counter: 0, cell_bounds: std::collections::HashMap::new(), cell_measurements: std::collections::HashMap::new(), board_scroll: ScrollHandle::new(), @@ -134,15 +146,38 @@ impl FlowEditor { self.document.version_vector() } - pub fn collaboration_updates_since(&self, version: &VersionVector) -> anyhow::Result> { - self.document.updates_since(version) + pub fn collaboration_projection_snapshot(&self) -> FlowProjectionSnapshot { + self.document.projection_snapshot() + } + + pub fn collaboration_frontier(&self) -> FlowFrontier { + self.document.frontier() } - pub fn import_collaboration_updates(&mut self, bytes: &[u8], cx: &mut Context) -> anyhow::Result<()> { - self.document.import_updates(bytes)?; + pub fn collaboration_updates_since(&self, version: &VersionVector) -> anyhow::Result { + self.document.export_updates_for(version) + } + + pub fn import_collaboration_updates(&mut self, bytes: &[u8], cx: &mut Context) -> anyhow::Result> { + let events = self.document.import_remote_update(bytes)?; self.sync_cell_editors(cx); self.changed(self.active_cell, cx); - Ok(()) + Ok(events) + } + + pub fn apply_collaboration_transaction( + &mut self, + transaction_id: FlowTransactionId, + base_frontier: &[u8], + change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>, + cx: &mut Context, + ) -> anyhow::Result { + let result = self + .document + .apply_projection_transaction(transaction_id, base_frontier, change)?; + self.sync_cell_editors(cx); + self.changed(self.active_cell, cx); + Ok(result) } pub fn active_sheet(&self) -> Option { @@ -554,10 +589,14 @@ impl FlowEditor { pub fn resolve_pending(&mut self, _cx: &mut Context) {} fn render_sheet(&self, sheet_id: SheetId, cx: &mut Context) -> AnyElement { - let sheet = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id); - let Some(sheet) = sheet else { + let Some(real_sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { return div().child("Select a sheet").into_any_element(); }; + // During a drag the layout stays stable (no reflow) so drop locations don't move under the pointer: + // the dragged cell holds its slot as a faded placeholder, its children stay put, and the landing is + // shown by a directional accent on the target cell. The real subtree move still happens on drop. + let sheet = real_sheet; + let drop_target = self.drag_drop_target(real_sheet); let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { return div().child("Invalid sheet type").into_any_element(); }; @@ -578,7 +617,27 @@ impl FlowEditor { let control_size = px(20.0 * zoom); let control_icon_size = px(12.0 * zoom); let cell_layout = sheet_cell_layout(sheet, &self.cell_measurements, zoom); + // The drop preview marks the target with a thin, non-displacing landing bar. A full-card-height + // gap used to be inserted here, which shoved the target — and everything below it — down by a + // whole card; for a Before landing that slid the very cell you were aiming at out from under the + // pointer (making fine placement feel like chasing a moving target, and silently handing the drop + // off to the column handler at family boundaries). A slim bar keeps the pointer over its target. + // Tuple: (column index, top in layout space, bar height). + let column_gap: Option<(usize, f32, f32)> = drop_target.and_then(|(target, edge)| { + let target_cell = sheet.cells.iter().find(|cell| cell.id == target)?; + let target_layout = cell_layout.get(&target)?; + let target_column = definition.columns.iter().position(|column| column.id == target_cell.column_id)?; + // Keep well under a card's half-height (min card is 54px) so opening the bar never displaces the + // target far enough for the pointer to fall off it. + let height = 5.0 * zoom; + Some(match edge { + DropEdge::Before => (target_column, target_layout.top, height), + DropEdge::After => (target_column, target_layout.top + target_layout.height, height), + DropEdge::Child => (target_column + 1, target_layout.top, height), + }) + }); let board_width = px((32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0) * zoom); + let column_count = definition.columns.len(); let weak_editor = cx.entity().downgrade(); let weak_connector_editor = weak_editor.clone(); let mut children_by_parent: std::collections::HashMap> = std::collections::HashMap::new(); @@ -601,6 +660,40 @@ impl FlowEditor { .flex() .gap(px(16.0 * zoom)) .p(px(16.0 * zoom)) + // Board-level fallback so the landing preview never freezes in a dead zone. The per-column + // handlers only act inside a column div, but the 16px inter-column flex gaps and the outer + // padding lie outside every column, so a pointer crossing between columns would otherwise stop + // updating the drop. This runs first (capture phase, parent-before-child), so a real column or + // cell handler still overrides it whenever the pointer is genuinely inside one; it only "wins" + // in the strips no column covers. + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + let bounds = event.bounds; + let position = event.event.position; + if !bounds.contains(&position) { + return; + } + editor.update_drag_autoscroll(position, window, cx); + if editor.cursor_over_live_cell(position) { + return; + } + let zoom = editor.board_zoom; + let column_width = 280.0 * zoom; + let stride = (280.0 + 16.0) * zoom; + let relative = (position.x - bounds.left()).as_f32() - 16.0 * zoom; + if relative < 0.0 { + editor.update_column_drop(0, position.y, cx); + return; + } + let raw_index = (relative / stride).floor(); + let within = relative - raw_index * stride; + let index = raw_index as usize; + if within <= column_width && index < column_count { + // Inside a real column — its own handler already covers this point, so don't fight it. + return; + } + // In the gap after `index` (or the trailing padding) — snap to the nearest real column. + editor.update_column_drop(index.min(column_count.saturating_sub(1)), position.y, cx); + })) .children(definition.columns.iter().enumerate().map(|(column_index, column)| { let side_palette = flow_side_palette(column.side, cx); let side_color = side_palette.base; @@ -610,8 +703,20 @@ impl FlowEditor { .w(px(280.0 * zoom)) .flex_none() .flex_col() - .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + // Same as the cell handler: `on_drag_move` is not hit-tested, so gate on this column's own + // bounds, and defer to the cell handler whenever the pointer is actually over a live cell. + if !event.bounds.contains(&event.event.position) { + return; + } + editor.update_drag_autoscroll(event.event.position, window, cx); + if editor.cursor_over_live_cell(event.event.position) { + return; + } editor.update_column_drop(column_index, event.event.position.y, cx); + if let Some(intent) = editor.pending_cell_drop { + editor.log_drag_over_column(column_index, event.event.position, intent); + } })) .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))) .child( @@ -655,12 +760,38 @@ impl FlowEditor { ) .child(div().h(px(12.0 * zoom)).flex_none()) .children({ + // Render this column's cells in vertical (layout `top`) order, not sheet order. A cell's + // vertical band comes from the family tree, so after some moves sheet order diverges from + // top order; iterating in sheet order with push-down-only spacers would stack cells wrong + // (e.g. a first child's subtree rendering *below* a later sibling's). + let mut column_cells: Vec<_> = sheet.cells.iter().filter(|cell| cell.column_id == column.id).collect(); + column_cells.sort_by(|a, b| { + let top = |id| cell_layout.get(id).map(|layout| layout.top).unwrap_or(0.0); + top(&a.id).partial_cmp(&top(&b.id)).unwrap_or(std::cmp::Ordering::Equal) + }); + // Open the landing gap (if it belongs to this column) before the first cell at/after its + // top, shifting the rest of the column down by the gap's height. Everything else stays put. + let mut gap_pending = column_gap.filter(|(gap_column, _, _)| *gap_column == column_index).map(|(_, top, height)| (top, height)); let mut previous_bottom = 0.0; - sheet.cells.iter().filter(|cell| cell.column_id == column.id).map(|cell| { + let mut shift = 0.0; + let mut elements: Vec = column_cells.into_iter().map(|cell| { let id = cell.id; + let is_ghost = self.dragging_cell == Some(id); let layout = cell_layout.get(&id).copied().unwrap_or_default(); - let spacer_height = px((layout.top - previous_bottom).max(0.0)); - previous_bottom = layout.top + layout.height; + let gap_slot = if let Some((gap_top, gap_height)) = gap_pending + && layout.top >= gap_top - 0.5 + { + let gap_spacer = px((gap_top - previous_bottom).max(0.0)); + previous_bottom = gap_top + gap_height; + shift = gap_height; + gap_pending = None; + Some((gap_spacer, px(gap_height))) + } else { + None + }; + let cell_top = layout.top + shift; + let spacer_height = px((cell_top - previous_bottom).max(0.0)); + previous_bottom = cell_top + layout.height; let label: SharedString = cell .summary_text() .unwrap_or_else(|_| "Invalid rich text".into()) @@ -679,12 +810,18 @@ impl FlowEditor { } let cell_editor = self.cell_editors.get(&id).cloned(); let reply_editor = cx.entity().clone(); - let is_drop_target = self.pending_cell_drop.is_some_and(|destination| { - matches!(destination, CellDropDestination::Relative(target, _) | CellDropDestination::ChildOf(target) if target == id) - }); div() .w_full() .flex_col() + .when_some(gap_slot, |this, (gap_spacer, gap_height)| { + this.child(div().h(gap_spacer).flex_none()).child( + div() + .w_full() + .h(gap_height) + .rounded(px(2.0 * zoom)) + .bg(side_palette.active), + ) + }) .when(spacer_height > px(0.0), |this| this.child(div().h(spacer_height).flex_none())) .on_children_prepainted({ let weak_editor = weak_editor.clone(); @@ -715,6 +852,16 @@ impl FlowEditor { }) .hover(|style| style.bg(side_palette.hover.opacity(0.12))) .cursor_pointer() + .when(active != Some(id) && !is_ghost, |this| { + // The whole card (except the one being edited) is a drag handle; a plain click still + // selects because GPUI only starts a drag past a small movement threshold. + let weak = weak_editor.clone(); + let drag_label = label.clone(); + this.on_drag(FlowCellDrag { cell_id: id }, move |drag, _, _, cx| { + let _ = weak.update(cx, |editor, cx| editor.begin_cell_drag(drag.cell_id, cx)); + cx.new(|_| FlowCellDragPreview { label: drag_label.clone() }) + }) + }) .on_mouse_down(MouseButton::Left, cx.listener(|editor, _, _, cx| { if editor.annotation_tool != AnnotationTool::None { editor.set_annotation_tool(AnnotationTool::None, cx); @@ -727,23 +874,48 @@ impl FlowEditor { cell_editor.read(cx).focus_handle(cx).focus(window); } })) - .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, _, cx| { - let right_zone = event.event.position.x >= event.bounds.left() + event.bounds.size.width * 0.72; - let destination = if right_zone && can_receive_child { - CellDropDestination::ChildOf(id) - } else if event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0 { - CellDropDestination::Relative(id, RelativePosition::Before) - } else { - CellDropDestination::Relative(id, RelativePosition::After) - }; - editor.update_cell_drop(destination, cx); - cx.stop_propagation(); - })) - .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| { - editor.finish_cell_drop(drag.cell_id, cx); - cx.stop_propagation(); - })) - .when(is_drop_target, |this| this.border(px(2.0 * zoom)).border_color(side_palette.active)) + // Ghost cells (the dragged subtree drawn in its previewed drop position) must not be drop + // targets. Otherwise the ghost sits under the cursor, captures the drag, resolves to a + // self-referential intent, and the placement oscillates and pins near the source/parent. + .when(!is_ghost, |this| { + this + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + // `on_drag_move` is dispatched in the CAPTURE phase to every registered element + // (not hit-tested), parent-before-child — so the column handler above has already + // run this frame. Each handler hit-tests itself against its own bounds; the actual + // column-vs-cell arbitration is the `cursor_over_live_cell` guard in the column + // handler, NOT the `stop_propagation` below (which fires too late to gate the + // already-executed column handler and only suppresses later-painted siblings). + if !event.bounds.contains(&event.event.position) { + return; + } + // Left 60% reorders siblings (before/after by vertical half); right 40% nests as a + // child (first/last by vertical half). The wider child zone is far easier to hit + // than the old 28% strip, and top/bottom finally makes "first child" reachable. + let in_child_zone = event.event.position.x >= event.bounds.left() + event.bounds.size.width * 0.6; + let upper_half = event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0; + let destination = if in_child_zone && can_receive_child { + if upper_half { + FlowDropIntent::FirstChildOf(id) + } else { + FlowDropIntent::LastChildOf(id) + } + } else if upper_half { + FlowDropIntent::BeforeSibling(id) + } else { + FlowDropIntent::AfterSibling(id) + }; + editor.update_cell_drop(destination, cx); + editor.update_drag_autoscroll(event.event.position, window, cx); + editor.log_drag_over_cell(id, event.event.position, event.bounds, destination); + cx.stop_propagation(); + })) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| { + editor.finish_cell_drop(drag.cell_id, cx); + cx.stop_propagation(); + })) + }) + .when(is_ghost, |this| this.opacity(0.5).border_dashed().border_color(side_palette.active)) .child( div() .id(("flow-cell-drag-handle", id.as_u128() as u64)) @@ -755,7 +927,14 @@ impl FlowEditor { .text_color(cx.theme().muted_foreground) .child("⠿") .on_mouse_down(MouseButton::Left, cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) - .on_drag(FlowCellDrag { cell_id: id }, |_, _, _, cx| cx.new(|_| FlowCellDragPreview)), + .on_drag(FlowCellDrag { cell_id: id }, { + let weak = weak_editor.clone(); + let drag_label = label.clone(); + move |drag, _, _, cx| { + let _ = weak.update(cx, |editor, cx| editor.begin_cell_drag(drag.cell_id, cx)); + cx.new(|_| FlowCellDragPreview { label: drag_label.clone() }) + } + }), ) .when(can_receive_child, |this| { this.child( @@ -786,7 +965,27 @@ impl FlowEditor { ) }), ) - }).collect::>()}) + .into_any_element() + }).collect(); + if let Some((gap_top, gap_height)) = gap_pending { + let gap_spacer = px((gap_top - previous_bottom).max(0.0)); + elements.push( + div() + .w_full() + .flex_col() + .child(div().h(gap_spacer).flex_none()) + .child( + div() + .w_full() + .h(px(gap_height)) + .rounded(px(2.0 * zoom)) + .bg(side_palette.active), + ) + .into_any_element(), + ); + } + elements + }) })) .child( canvas( @@ -856,6 +1055,14 @@ impl Render for FlowEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.apply_pending_camera_center(); self.refresh_active_cell_theme(cx); + if !cx.has_active_drag() && (self.pending_cell_drop.is_some() || self.dragging_cell.is_some()) { + self.pending_cell_drop = None; + self.dragging_cell = None; + self.drag_autoscroll = None; + // A drag that ended without an accepted drop (released over empty space) still flushes, tagged + // as an uncommitted attempt. + self.finish_drag_log(None, false); + } if self.pan_drag.is_some() { let editor = cx.entity(); window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| { @@ -871,15 +1078,17 @@ impl Render for FlowEditor { .relative() .size_full() .track_focus(&self.focus_handle) - .on_key_down(cx.listener(|editor, event: &KeyDownEvent, _, cx| { - if event.keystroke.key == "space" { + .on_key_down(cx.listener(|editor, event: &KeyDownEvent, window, cx| { + // Only arm panning when the board itself holds focus. Otherwise a space typed inside a focused + // cell editor would silently arm a pan (and a later click would pan instead of act). + if event.keystroke.key == "space" && editor.focus_handle.is_focused(window) { editor.space_pan_armed = true; cx.stop_propagation(); cx.notify(); } })) .on_key_up(cx.listener(|editor, event: &KeyUpEvent, _, cx| { - if event.keystroke.key == "space" { + if event.keystroke.key == "space" && editor.space_pan_armed { editor.space_pan_armed = false; editor.finish_space_pan(cx); cx.stop_propagation(); diff --git a/crates/flowstate/src/flow/editor/drag_drop.rs b/crates/flowstate/src/flow/editor/drag_drop.rs index 2f7cc8f5..6df20e85 100644 --- a/crates/flowstate/src/flow/editor/drag_drop.rs +++ b/crates/flowstate/src/flow/editor/drag_drop.rs @@ -1,6 +1,7 @@ -use flowstate_flow::{CellId, RelativePosition}; -use gpui::{Context, IntoElement, Render, Window, div, prelude::*}; +use flowstate_flow::{CellId, FlowDropIntent, Sheet}; +use gpui::{Context, IntoElement, Pixels, Point, Render, SharedString, Window, div, point, prelude::*, px}; use gpui_component::ActiveTheme as _; +use gpui_component::PixelsExt as _; use super::FlowEditor; @@ -9,32 +10,122 @@ pub(super) struct FlowCellDrag { pub(super) cell_id: CellId, } -pub(super) struct FlowCellDragPreview; +/// Which edge of the target cell to accent as the drop indicator. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum DropEdge { + Before, + After, + Child, +} -#[derive(Clone, Copy)] -pub(super) enum CellDropDestination { - Relative(CellId, RelativePosition), - ChildOf(CellId), - OrphanInColumn { column_index: usize, insertion_index: usize }, +pub(super) struct FlowCellDragPreview { + pub(super) label: SharedString, } impl Render for FlowCellDragPreview { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() + .max_w(px(280.0)) .px_2() .py_1() .rounded(cx.theme().radius) .bg(cx.theme().popover) .border_1() .border_color(cx.theme().border) - .child("Move argument") + .text_color(cx.theme().popover_foreground) + .overflow_hidden() + .child(if self.label.is_empty() { + SharedString::from("Argument") + } else { + self.label.clone() + }) } } impl FlowEditor { - pub(super) fn update_cell_drop(&mut self, destination: CellDropDestination, cx: &mut Context) { - self.pending_cell_drop = Some(destination); - cx.notify(); + /// Called when a card drag actually starts (past the drag threshold). Recording the source cell + /// lets [`Self::drag_preview_sheet`] reflow the board as if the cell had already been picked up. + pub(super) fn begin_cell_drag(&mut self, cell_id: CellId, cx: &mut Context) { + if self.dragging_cell != Some(cell_id) { + self.dragging_cell = Some(cell_id); + self.pending_cell_drop = None; + self.start_drag_log(cell_id); + cx.notify(); + } + } + + /// The cell (and which edge of it) to accent as the current drop target, or `None` when there's no + /// valid landing. Only landings the committed move would accept are shown, so hovering an illegal + /// target (e.g. the cell's own descendant) shows nothing. + pub(super) fn drag_drop_target(&self, sheet: &Sheet) -> Option<(CellId, DropEdge)> { + let dragging = self.dragging_cell?; + let intent = self.pending_cell_drop?; + let sheet_id = self.active_sheet?; + self.document.preview_move_cell_subtree(sheet_id, dragging, intent)?; + match intent { + FlowDropIntent::BeforeSibling(target) => Some((target, DropEdge::Before)), + FlowDropIntent::AfterSibling(target) => Some((target, DropEdge::After)), + // Nesting maps to the boundary of the parent's existing children so the exact insertion point is + // visible; with no children yet, accent the parent's child-side edge. + FlowDropIntent::FirstChildOf(target) => Some( + sheet + .cells + .iter() + .find(|cell| cell.parent_id == Some(target)) + .map_or((target, DropEdge::Child), |child| (child.id, DropEdge::Before)), + ), + FlowDropIntent::LastChildOf(target) => Some( + sheet + .cells + .iter() + .rev() + .find(|cell| cell.parent_id == Some(target)) + .map_or((target, DropEdge::Child), |child| (child.id, DropEdge::After)), + ), + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => { + if let Some(cell) = sheet.cells.get(insertion_index) { + Some((cell.id, DropEdge::Before)) + } else { + let column = self.document.projection().format.sheet_type(sheet.sheet_type_id)?.columns.get(column_index)?.id; + sheet.cells.iter().rev().find(|cell| cell.column_id == column).map(|cell| (cell.id, DropEdge::After)) + } + }, + } + } + + /// Whether the pointer is over a cell other than the one being dragged, so the column handler can + /// defer to that cell's own drop zones. The dragged cell holds its slot as a faded placeholder, so + /// the pointer passes through it to the column beneath. + pub(super) fn cursor_over_live_cell(&self, position: Point) -> bool { + self.cell_bounds.iter().any(|(id, bounds)| Some(*id) != self.dragging_cell && bounds.contains(&position)) + } + + /// A parent in `parent_column` with no children yet whose card vertically contains `y`, so a drop in + /// the (empty) `child_column` beside it can adopt it. The dragged cell is skipped so it can't parent + /// to itself. + fn childless_parent_at_row(&self, parent_column: usize, child_column: usize, y: gpui::Pixels) -> Option { + let projection = self.document.projection(); + let sheet = projection.sheets.iter().find(|sheet| Some(sheet.id) == self.active_sheet)?; + let definition = projection.format.sheet_type(sheet.sheet_type_id)?; + let parent_column = definition.columns.get(parent_column)?.id; + let _ = definition.columns.get(child_column)?; + sheet + .cells + .iter() + .filter(|cell| cell.column_id == parent_column && Some(cell.id) != self.dragging_cell) + .filter(|cell| !sheet.cells.iter().any(|other| other.parent_id == Some(cell.id))) + .find(|cell| self.cell_bounds.get(&cell.id).is_some_and(|bounds| y >= bounds.top() && y <= bounds.bottom())) + .map(|cell| cell.id) + } + + pub(super) fn update_cell_drop(&mut self, destination: FlowDropIntent, cx: &mut Context) { + if self.pending_cell_drop != Some(destination) { + self.pending_cell_drop = Some(destination); + cx.notify(); + } } pub(super) fn update_column_drop(&mut self, column_index: usize, y: gpui::Pixels, cx: &mut Context) { @@ -64,12 +155,22 @@ impl FlowEditor { && let Some((_, below)) = column_cells.get(below_position) && above.parent_id == below.parent_id { - self.update_cell_drop(CellDropDestination::Relative(below.id, RelativePosition::Before), cx); + self.update_cell_drop(FlowDropIntent::BeforeSibling(below.id), cx); + return; + } + // The natural "make a child" gesture is dropping in the child column beside the parent — but a + // childless parent has nothing there, so it would otherwise fall through to an orphan drop. Adopt + // it as the parent's child when the pointer sits at that parent's row. + if let Some(parent) = column_index + .checked_sub(1) + .and_then(|parent_column| self.childless_parent_at_row(parent_column, column_index, y)) + { + self.update_cell_drop(FlowDropIntent::LastChildOf(parent), cx); return; } let insertion_index = below_position.map_or(sheet.cells.len(), |position| column_cells[position].0); self.update_cell_drop( - CellDropDestination::OrphanInColumn { + FlowDropIntent::RootInColumn { column_index, insertion_index, }, @@ -77,64 +178,67 @@ impl FlowEditor { ); } - pub(super) fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { - let Some(destination) = self.pending_cell_drop.take() else { - return; - }; - let Some(sheet_id) = self.active_sheet else { + /// While dragging near a viewport edge, scroll the board toward it. Driven by a self-rescheduling + /// frame loop so scrolling continues even when the pointer holds still at the edge. + pub(super) fn update_drag_autoscroll(&mut self, pointer: Point, window: &mut Window, cx: &mut Context) { + let bounds = self.board_scroll.bounds(); + if bounds.size.width <= px(1.0) || bounds.size.height <= px(1.0) { return; + } + const MARGIN: f32 = 56.0; + const MAX_SPEED: f32 = 24.0; + let ramp = |distance: f32| -> f32 { + if distance <= 0.0 { + MAX_SPEED + } else if distance < MARGIN { + MAX_SPEED * (1.0 - distance / MARGIN) + } else { + 0.0 + } }; - let Some(sheet) = self.document.projection().sheets.iter().find(|sheet| sheet.id == sheet_id) else { + let vx = ramp((pointer.x - bounds.left()).as_f32()) - ramp((bounds.right() - pointer.x).as_f32()); + let vy = ramp((pointer.y - bounds.top()).as_f32()) - ramp((bounds.bottom() - pointer.y).as_f32()); + if vx == 0.0 && vy == 0.0 { + self.drag_autoscroll = None; return; - }; - let Some(definition) = self.document.projection().format.sheet_type(sheet.sheet_type_id) else { + } + self.drag_autoscroll = Some(point(px(vx), px(vy))); + self.schedule_drag_autoscroll(window, cx); + } + + fn schedule_drag_autoscroll(&mut self, window: &mut Window, cx: &mut Context) { + if self.drag_autoscroll_scheduled { return; + } + self.drag_autoscroll_scheduled = true; + cx.on_next_frame(window, |editor, window, cx| { + editor.drag_autoscroll_scheduled = false; + let Some(velocity) = editor.drag_autoscroll else { + return; + }; + if !cx.has_active_drag() || editor.dragging_cell.is_none() { + editor.drag_autoscroll = None; + return; + } + editor.set_user_scroll_offset(editor.board_scroll.offset() + velocity); + cx.notify(); + editor.schedule_drag_autoscroll(window, cx); + }); + } + + pub(super) fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { + let destination = self.pending_cell_drop.take(); + self.dragging_cell = None; + self.drag_autoscroll = None; + let committed = match (destination, self.active_sheet) { + (Some(destination), Some(sheet_id)) => self.document.move_cell_subtree(sheet_id, dragged, destination).is_ok(), + _ => false, }; - let (column_index, insertion_index, parent_id) = match destination { - CellDropDestination::Relative(target, position) => { - if dragged == target { - return; - } - let Some(target_index) = sheet.cells.iter().position(|cell| cell.id == target) else { - return; - }; - let target_cell = &sheet.cells[target_index]; - let Some(column_index) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { - return; - }; - let insertion_index = match position { - RelativePosition::Before => target_index, - RelativePosition::After => target_index + 1, - }; - (column_index, insertion_index, target_cell.parent_id) - }, - CellDropDestination::ChildOf(target) => { - if dragged == target { - return; - } - let Some(target_cell) = sheet.cells.iter().find(|cell| cell.id == target) else { - return; - }; - let Some(parent_column) = definition.columns.iter().position(|column| column.id == target_cell.column_id) else { - return; - }; - let Some(child_column) = parent_column.checked_add(1).filter(|index| *index < definition.columns.len()) else { - return; - }; - let insertion_index = self.document.child_append_index(sheet_id, target).unwrap_or(sheet.cells.len()); - (child_column, insertion_index, Some(target)) - }, - CellDropDestination::OrphanInColumn { - column_index, - insertion_index, - } => (column_index, insertion_index, None), - }; - if self - .document - .move_cell(sheet_id, dragged, column_index, insertion_index, parent_id) - .is_ok() - { + self.finish_drag_log(destination, committed); + if committed { self.changed(Some(dragged), cx); + } else { + cx.notify(); } } } diff --git a/crates/flowstate/src/flow/editor/telemetry.rs b/crates/flowstate/src/flow/editor/telemetry.rs new file mode 100644 index 00000000..cc1c82c8 --- /dev/null +++ b/crates/flowstate/src/flow/editor/telemetry.rs @@ -0,0 +1,216 @@ +//! Env-gated drag-and-drop ergonomics telemetry. +//! +//! When `FLOWSTATE_DRAG_LOG` is set (to a path, or `1`/`true` for a default file), every cell drag is +//! recorded as one JSON object per line: the dragged cell, every zone/intent transition the pointer +//! passed through (with precise per-cell offsets), and the final committed result. Unset = zero cost. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Instant; + +use flowstate_flow::{CellId, FlowDropIntent}; +use gpui::{Bounds, Pixels, Point}; +use gpui_component::PixelsExt as _; +use serde_json::{Value, json}; + +use super::FlowEditor; + +/// In-flight recording for a single drag, flushed as one JSON line when the drag ends. +pub(super) struct DragLogSession { + drag_id: u64, + dragged: String, + sheet: String, + zoom: f32, + started: Instant, + last_key: Option, + samples: Vec, +} + +fn log_path() -> Option<&'static PathBuf> { + static PATH: OnceLock> = OnceLock::new(); + PATH + .get_or_init(|| { + std::env::var_os("FLOWSTATE_DRAG_LOG").and_then(|value| { + let value = value.to_string_lossy(); + match value.trim() { + "" | "0" | "false" => None, + "1" | "true" => Some(PathBuf::from("flow-drag-telemetry.jsonl")), + path => Some(PathBuf::from(path)), + } + }) + }) + .as_ref() +} + +fn enabled() -> bool { + log_path().is_some() +} + +fn append_record(record: &Value) { + let Some(path) = log_path() else { + return; + }; + match serde_json::to_string(record) { + Ok(line) => { + if let Err(error) = append_line(path, &line) { + eprintln!("flow drag telemetry write failed: {error}"); + } + }, + Err(error) => eprintln!("flow drag telemetry serialize failed: {error}"), + } +} + +fn append_line(path: &Path, line: &str) -> std::io::Result<()> { + use std::io::Write as _; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{line}") +} + +fn round(value: f32, places: u32) -> f32 { + let scale = 10f32.powi(places as i32); + (value * scale).round() / scale +} + +impl FlowEditor { + pub(super) fn start_drag_log(&mut self, dragged: CellId) { + if !enabled() { + self.drag_log = None; + return; + } + let dragged = self.cell_label(dragged); + let sheet = self + .active_sheet + .and_then(|id| self.document().projection().sheets.iter().find(|sheet| sheet.id == id).map(|sheet| sheet.name.clone())) + .unwrap_or_default(); + self.drag_log_counter += 1; + self.drag_log = Some(DragLogSession { + drag_id: self.drag_log_counter, + dragged, + sheet, + zoom: self.board_zoom(), + started: Instant::now(), + last_key: None, + samples: Vec::new(), + }); + } + + pub(super) fn log_drag_over_cell(&mut self, over: CellId, cursor: Point, bounds: Bounds, intent: FlowDropIntent) { + if self.drag_log.is_none() { + return; + } + let over_label = self.cell_label(over); + let intent_str = self.describe_intent(intent); + let valid = self.landing_is_valid(intent); + let width = bounds.size.width.as_f32().max(1.0); + let height = bounds.size.height.as_f32().max(1.0); + let offset_frac = [round((cursor.x.as_f32() - bounds.left().as_f32()) / width, 3), round((cursor.y.as_f32() - bounds.top().as_f32()) / height, 3)]; + let key = format!("cell:{over_label}:{intent_str}"); + let over = json!({ "kind": "cell", "label": over_label, "offset_frac": offset_frac }); + self.push_sample(key, over, cursor, intent_str, valid); + } + + pub(super) fn log_drag_over_column(&mut self, column_index: usize, cursor: Point, intent: FlowDropIntent) { + if self.drag_log.is_none() { + return; + } + let intent_str = self.describe_intent(intent); + let valid = self.landing_is_valid(intent); + let key = format!("column:{column_index}:{intent_str}"); + let over = json!({ "kind": "column", "index": column_index }); + self.push_sample(key, over, cursor, intent_str, valid); + } + + pub(super) fn finish_drag_log(&mut self, intent: Option, committed: bool) { + if self.drag_log.is_none() { + return; + } + let intent = intent.map(|intent| self.describe_intent(intent)); + let result = self.sheet_topology_snapshot(); + let Some(session) = self.drag_log.take() else { + return; + }; + let record = json!({ + "drag_id": session.drag_id, + "dragged": session.dragged, + "sheet": session.sheet, + "zoom": session.zoom, + "duration_ms": session.started.elapsed().as_millis() as u64, + "samples": session.samples, + "drop": { "intent": intent, "committed": committed, "result": result }, + }); + append_record(&record); + } + + fn push_sample(&mut self, key: String, over: Value, cursor: Point, intent: String, valid: bool) { + let board_pt = [round((cursor.x.as_f32() - self.viewport_origin.x) / self.board_zoom(), 2), round((cursor.y.as_f32() - self.viewport_origin.y) / self.board_zoom(), 2)]; + let Some(session) = self.drag_log.as_mut() else { + return; + }; + if session.last_key.as_deref() == Some(key.as_str()) { + return; + } + let t_ms = session.started.elapsed().as_millis() as u64; + session.last_key = Some(key); + session.samples.push(json!({ + "t_ms": t_ms, + "cursor": [round(cursor.x.as_f32(), 1), round(cursor.y.as_f32(), 1)], + "board_pt": board_pt, + "over": over, + "intent": intent, + "valid_landing": valid, + })); + } + + fn cell_label(&self, id: CellId) -> String { + self + .document() + .projection() + .sheets + .iter() + .find_map(|sheet| sheet.cells.iter().find(|cell| cell.id == id)) + .and_then(|cell| cell.summary_text().ok()) + .map(|text| text.lines().next().unwrap_or_default().trim().to_string()) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| format!("cell:{}", &id.to_string()[..8])) + } + + fn describe_intent(&self, intent: FlowDropIntent) -> String { + match intent { + FlowDropIntent::BeforeSibling(id) => format!("BeforeSibling({})", self.cell_label(id)), + FlowDropIntent::AfterSibling(id) => format!("AfterSibling({})", self.cell_label(id)), + FlowDropIntent::FirstChildOf(id) => format!("FirstChildOf({})", self.cell_label(id)), + FlowDropIntent::LastChildOf(id) => format!("LastChildOf({})", self.cell_label(id)), + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => format!("RootInColumn{{col:{column_index}, idx:{insertion_index}}}"), + } + } + + fn landing_is_valid(&self, intent: FlowDropIntent) -> bool { + self + .active_sheet + .zip(self.dragging_cell) + .is_some_and(|(sheet, dragged)| self.document().preview_move_cell_subtree(sheet, dragged, intent).is_some()) + } + + fn sheet_topology_snapshot(&self) -> Vec { + let projection = self.document().projection(); + let Some(sheet) = self.active_sheet.and_then(|id| projection.sheets.iter().find(|sheet| sheet.id == id)) else { + return Vec::new(); + }; + let definition = projection.format.sheet_type(sheet.sheet_type_id); + sheet + .cells + .iter() + .map(|cell| { + let column = definition.and_then(|definition| definition.columns.iter().position(|column| column.id == cell.column_id)); + json!({ + "label": self.cell_label(cell.id), + "column": column, + "parent": cell.parent_id.map(|parent| self.cell_label(parent)), + }) + }) + .collect() + } +} diff --git a/crates/flowstate/src/main.rs b/crates/flowstate/src/main.rs index cf6d0e72..45c5fb1e 100644 --- a/crates/flowstate/src/main.rs +++ b/crates/flowstate/src/main.rs @@ -18,22 +18,32 @@ impl Default for FlowstateAllocator { } } +// SAFETY: this implementation delegates every allocation and deallocation to a +// process-wide allocator implementation that satisfies `GlobalAlloc`. unsafe impl GlobalAlloc for FlowstateAllocator { #[cfg(not(windows))] unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: `GlobalAlloc::alloc` forwards the caller-provided layout unchanged + // to the selected global allocator. unsafe { rimalloc::Rimalloc.alloc(layout) } } #[cfg(windows)] unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: `GlobalAlloc::alloc` forwards the caller-provided layout unchanged + // to the selected global allocator. unsafe { mimalloc::MiMalloc.alloc(layout) } } #[cfg(not(windows))] unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: callers of `GlobalAlloc::dealloc` must provide a pointer/layout + // pair from this allocator; we forward that pair unchanged. unsafe { rimalloc::Rimalloc.dealloc(ptr, layout) } } #[cfg(windows)] unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: callers of `GlobalAlloc::dealloc` must provide a pointer/layout + // pair from this allocator; we forward that pair unchanged. unsafe { mimalloc::MiMalloc.dealloc(ptr, layout) } } } diff --git a/flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 b/flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 new file mode 100644 index 0000000000000000000000000000000000000000..5e7ec4454022f1e215661d2792e64a1e3a81f078 GIT binary patch literal 13013 zcmV;`GAhkROix!vOfUce0002NG5`Po0000ewJ-euSjCkAnh0O6Ll7wRrUUWip+*P* z008)BCIKqOVzkUZ5>YWotG-qnjLBuIUqKD-w}FFA%m%71VA}fYSWS0HwC*0lAg6WLs&&Iyk256$uT_b9)(ejNfo%8er)B-Ac~;v!?XeCJZ8rp~vQ;hr|(k2bVr!XFDaY?|oiA)d`t3;E4n6w}b zp^ldf(xxhrn~4=%(l5)hh*Jh0CbCquk-!WE10CQqHg?!N4WU;qjO|7hYjxXDYdZZP zqDn(O67kMKJ%W0)&T&1SgW^q=2o2<@!I`iijQS@7H4Y|F^3d-w2W|mB9jpK`HYNh2 z3WpTH;Q3WBB2b#*hN|l73Z4*le1r(I=*0J}aXnr`;|z%me0<9AM&g6LJY!e1K+59u z&?mBH0}l>BLCJ&?jFK>cnqfMEJQPm5LcDWnSK6f#;Cf7eeiS`iM*^P-hPf3S=-6Xs zN3ZCtTALK30MF3ikvbmC@gad(i#;evVH^?pY&di;?b<)*Cs3VfwQzxC%Ax><)TJE| z;wK?b8|@qFT#tdS0tof!J!?0DfXl+Q#ECGJs6&Ou1WiZ;KzZ{}Ppc!bF9``T!tBn+ zumT`PT0}jx!m4KfQv0K%8Q0vZCa-Gr>sfUTYFdxgq@Th~6$W*1G}uETtpu4c29zn2 z<3fvveh)P?<}Ayk5JBZGj}ywLDw32B-AkA3)XY!V0K`2a^Rej*i6_OeHDscY7@GRN zoGwXU&njC(dmPD5qoJ<>KdK(uz=8#|u|#$h=#Y`)$;%rqS}1gxqfmChl&HHBHT(b# z7((@j3>{8{t(l#0gC&p(EYXL@U8E48aI8=<7JvhJfJD-CN_rVWZA96Xy*kRS?466U z+i91lH5EkkSkNCy5Qs&R<6sts2#QEnGt}TAdg%8MB=@NV1*Mu&Z%f~bA)2*=hwiP= z{UiPapq?EUiu{PP@n;NK*^?+Y_Sk^(eL3yQuNR`nUVi>VZ`i{MSSBi6C7j?*@Z7{E zOrAW{BX~*R2?vAk%*C274_0NoD5!+XLZU{5X1q5BTQ5;6$rq3Z89@KWkUlKa11ik9A0uN zf6Ro*QufNsu&`~BedzKhZ0w8*EIofj_~3$w2wZc9FKP&N9X|&K;Qr3$KPq?k6c1!m zJgVy-j%`+oXQyHAdfkq=H@dcGjyO3U#UFA^9Xfi<6`wfhiaXwlJDyJG8;;`K4<_AQ zk&cOjuK2?l*DN~am~BNkxE^lBScy~?<>GMA^%yHov74ZnB#TOtja^EYXm_s=06o*u zx*MrYm#`j8G{9QBrb{rr#b>fp1oWKs)jX$e>`>)#hd&Y4YW?BlnmH;_WC9}!dBhA1 zC=dM}9WIrDl7uv7h=DS9yd;&iAUzKbB9$ApE7Hzk zb=}CiP<6J{u~cW%)}gpshsw_7XUVbRjU8`va&bI(^q4Ca@O<+j_jK;)5tSSRUV0$f zT^Gn0?qX=20=C(@`=xy#tOplx_sr%nuv+zjq-%~^4*#{fkiv`b&WDDhZAtANZyJ%R)ux4`URAgv z?*8t+y8G%v#oCCB$JI(7g;_;a1(suK$Tl$u1D}XQ3J7zbC-bJ&)BeX?+nbKK9dS40 zLPY&Qeo2E;AP$xl#1=A3GWaAz$YB*^)uWP&E*a#g0P)7jm{*i+w#7;rg$<)ENkS;; z8}XIq%^2IOf<^>b15_o%mQf*?23YHJJw`e-ogtJ751O2ohtQsnAX5)@y%j&Ky|FYR z?)scy4)x8^9(`QatFweBxuo9jEkbNCHAn zF{Vt{1ZZLs!Ntzq7%~}}hzROqMffSyl&RYUS^^;SWrLwH0)S_SGc-I9dWx}Rx?=_i zb|SPh%^0%=*s$Da9Ks)7|x5aKQO3=*? z5FF}^D015qewkV_&9Gq$K&frb5kevUL=eFNXkG}tY%H0Y5z#<&Ne>K6w5Ub@l=ulV z34c!?Z*)u@a&K;KW57ubSWsaACq&}pFtgBAK^!oMg!xIwY(CA3Kib~l;?|@x2$bju z=&osm3|W?E&I9C4m^-a+r^#O}1J$simOKJf)=Hnso?R?!e=m>9pE>|p`vdiV@HsfR zXPdErI5@YcIlAW)$DFZ%I5;<80da6{I!r7q8P-HL!I;RW(G4lqG`fK;W8)KEI*m+c z48u<)%=K(G;iOwLYzZ@Cqw|>*Gd{(dv5^_pbQ+sVXINX9#Z?<0Tx1Tz+wh!OkX_QW@q918W+Q&#y3J z)6E$h-NM|MMx|3M8Mn24aXp((WEk=bEG%XsjZ3ju%;&t0`n@=(07cvLc{jQQnsi@B0Yuw~dXEG!oL zavGa(GR)`(=8TO@uw`rnL$)R1wy!3a+ZWTRjK9Fd+`gE`uox+3L@JZ9F>baojZ9=% zSkn1Kay`GklyR6CnA;cf%P7{?WEzz*92=2hN--nY*Rl=yl@#L&i{T`g66str!H~u; zXPcO@8H>fnwq=rOY%Y^wNwH_l<#dB%vF6v38P>)$wtdB7Mki7k8)45RpYO)+D$$rLkw zA(63B$!ud9+rq-0Pq(%u7adcFT%4Qn3D;!O_0XZ?O}Ikg;Qo4p`)e=v*QwCo{Z;=r zQ3MOPq!`4_3%|>|+(k4R?c{Ft01*&2BBWK&ej>#Q6YgYssb_#gjm_ia;@|>ayz;K2 z*7UDs*OMBqzjpV+zc%Q7e{D@W7R~-&T|w=iV7+K1s-sBgKapK8 zI9&gPkr)0Gg5LK}oX~znvwvbc*0X)J)l&soug1C?sC_loi+P`P6bSul+4X?K_0@{J z@T&!S-&a%9Zbh@Nw*3mO{WP@`1zDfQy8A%;X{;Bk^l29a{b||tqK50ICGf(Z_Q3mo zTAKDe&3@W;;amGAw99FNEI{p>VBMtxM|+S-`zEsMK@Hb8LF9$s1fchQ6DG7PHTx#E z6PfLoNZqO3-6TQQpY}_z?oM%6-&^7s==DnsdEqbd=Y7*j+m}={)TODWJM7KboJ{>(S7(Ptn2@(*Cm#0fMYP?VqvkM%ko2 zh}ZsEcD;4D{#o>e|E$mJ^=I3K`p>jW(ZWAFk!eyEpY}z|-Kie{h0_SRTbl9lSIK_u;u$o{5>>yPMr z;UBT*{SoiW2S$UgK?4;C`ts*OHQkXq6wLyo+o_7}i&z~4G=-rOfg5NK>T!~3^&Ur# zdVLX|Uid}mSyy+D`?8M_#kZdW_3ih5r(-dSZ$nYYdDeG#A-0bx+d)@h4OYE|=<%8}!=JMCOR4*C)L-hyAVk$% zdS$0_KJEKR!JJ-E(R%etnt1y>d*}1~E zeg4tBHGha?<#8mpMw!apy$b3XWVb@ zZ0o@V+RxeFu!ibmN!JMfLew9Nr*-|{ONYw7_0Hu`O0q3yW%!o2m)w0#;jZ>7hve?6 zdj$79?ylXJ?oHf1b!Xl02)!`PTXh8+_~$Mu@By(8bn$zY0`Rx$*DBjafXYxpVubWF zXq}Z)XMqJ&gRRw?sx6h&V*%6k=c;M0!fJCm^(hpq9T*}DgYREZpQHTKJa2!-@p-GkztPu~AIv=4MP~c{%V3Q8jV^yfX5f82ViK*k+c4z zbbS;DR&v)6K&Le|kWxC&9&}eTf#>cHp9DiXxyfC!4VF-qCq*}K@~4Z}{AMBqCECW8H@E`$Jz9>o+WKbo!^w&(Pvs4k>Hf^z}0GRwYU$qP*fHDe&u8DNGt zsd%7y=o6tpQ=geJ6`W1s161jX2$d>Y9tsl$ZMw8;!_Opd{SSItkM*FiQzfH_2sRW* zsLhZ9Mx_%0hEyyc`b_jNN%)ZRMdCx4E)XahA%ZA;=pIzsPBHw1uupUXsH9|x)&Z0S zO5j1#z+_32@AFTBw;nyKzQNF^92B{Z3@#sK`J_bA$c&DR1f5u6*^xdD^)y3$6ttBT1Nt(#lPVas87+O zv>}=plFA4Gy}(Pp^iaNZ;$#Iu2q0Q8(M_{WBhCPMXqi;GzqSXuF?VMltRw(6g43WN zo0!NE=04|;H?5zxyo95Tjw#p1_N%irqytF?Ot8j+TANiM86M(<8rCVPz8tQ{Ins7? zP-X#=E>j`Aj?h(k9;z?^)k8l?4j*bA0oD;o)|ipwp=SbT^iVie%ig(EohoxZE*s%G z5;)-vw2BW(SH?=1;bKTi0z}?qIi`1(1tNpR&sbTeEHl15%K~QRJhV)z?y0yIX`w32 zIAx&Rp{=O_V#z+gFQ=-MCAX#WnOrs&I&jDdp@bJIMEZR22GXD&`UF^7GzChH4JU+g z7}zcR%z>!$Pz_yOw)cr!x>U{U2B{FLJPzRk8ZBpsCKC`RWG3>|3Z7PT82NbU0|{a4 zQeT!KByKoy1Ok?xxPpa;?xj}#QcalCMTCi)2<|$iIWcqSN)McW%Ik*U9@pbNM(((d z1Y+%31IX{ZNI(Iz1(JsIE_RN|)An>=v*d&5SrIT>h;1VUv{ZR$g|sWul8lRsf#B=; z_eAW59^1Kx4T*xn=$bA_9>|DTrZB3D+cDvaou_7wJ}D4zdV5YKPA(sOc<3H5$9^(D z!I*-M6zhXT6qPBG%2;G5aYq8}`!ZVwltT1aN?P6!f(vsaqzrHZ4lkN0j2R7# z)ftsP0cO5bg@VTi2~TMO@MRg208<7HsL6Oz|mUxr|Y4zy)f#spQOoJdssG+RRNj~9lerR#xnD){oJ68z0)fA5{91w8l5gV8r zX;($4nTN5D=5z_`!S&dK9};tY4Y=oKaJthWiwWltG#_VtoDt;0js-JUCbWqvEjzw| z$nf%ZC&c6!5a7}0Md^sM$f1QWmE9J1XW5t>0^gbZ)y^H&{pG8 zw*-@MrK$ih*>R2y!-ipB&#D9>+T#V*u(X^Kz_MkLvuuja0&FCC@F$a%hh{qqOz=bO z)1MH?GCtN+sR`Ps^Uysu%ue$Jy-JK27P3oSA%OW3HKK??OVS4N(aeVVy{ZeJu6hX@ ztqXy!Dd<9`1n2U9UBYwfY0fGa0)0&d2_9F-a<2chgJ;!=7g~Q3^bsi<2?KJ(;V_0l z5)^HQFe^PH>hAPq>3{=m*GXX~8j!|f5U`XH8aQVkiqL6u@oc_EuTO9Rj1dAc;Y=z; z$YqKoAr4#e6Z)y4DuS6M5_?gISi*W>g%Kr8Mt!jE%m9Hr<6?-D77QR4dGZ=#grG`o z2OUm9*d%0IL>o1}psm4o#2Ja1jzv(q7V zVApyg>6_7cQiS$h)+2Yu>x_1Hf;Q`6|9F8;SAmd;Oq%F5>*4gmSr4AKF6-H;-S3Gg zed~v$Uq;_$Jv*~HP5{+N&O<;plc58N)?EqOoQM6Rg*jc7fzEmI|CQmI^K5$IoafG4 zm-B3>tdp+wcIofwJ66QbzhBnHVUdi7M;Q!dw7U_s84vqME;(HlK_=qy={4ig^uigB zowqLIk%RQDmy$ji?Ih#b1sKu^ucWRY+$$`M=6=&2}=q zaJEC|tz$jxHGtp4;ow^zBz*_;9qVE1JQ|7=bx5wGV~m9{+FeO)uEYLOD^6GW(Ya1e z?NAx6xelfm&UNOzNphXtPV%Y;lD-(7m+Nf1Ob5;ZE(f%`k=jg${bO;5uCkMfMuK|H zbf7Ps>BxDHWIB6;Ydr$#htW_no&8a|VO^dxKo+wlM!O5C&2!j4zB+UjUnbtD(rcb` z>4o#0IB#8^v(q2i^Sc`juJs3`4@L*cb9R2B=KuXC=1@qMljAR_5JtQEsLgWNKT?3x zIr1`*$d_KT97`{p<-mFC*yK(P_&o!u|0De`dQJFZyHWi%6x;Enc~R_oAKTINLkPwLmf)9AAoG zw7ZSk42N}BLJs{|?ypbUHNyeng)8w(4R%sX1*rGHS>A!!kMqldnEJi6EvLoJ<{Kz^D^K5k=;DGOyH8; zuuv-${aL~vYxJ7iaPY#p&6+p2d8hJEG`)Q$DcC~G&(+kQOTPh))k4euW0u}> zqdJ7^Or+UoTJ)M1wSdfKw<$klu^}Snr%poJt@1+lHzmV0v!V3DnT?t?yL((R_<^D$ zC~Y$U^qUF{^ajUbkjh5!rKVX~HAYbhTXwE`Q(r^T=n1erw_33GW7(~QE&AmXv1rdp z*s_IVK?EdC6}_bLkT2>*61CuCyNE`k0kGJ_x1ej#w4=@h)1#QpC@QyUr$VW%7L5=A z)GHS)vh6e}w;x5XLTpG;EC|wfO;pRm)u*3{j}pNi-Ve+M{1a zQ8yzy|HOh<9QezpVbGqlfMf@COj+;|Ofihv97L(q0AIOi7L%QBtgRM3%KhFBqAcEQ zM^O~<`Zl8|;>{)urYZz&HKNCASI~w}Fz6u{g?(k?=U!?HNjp|5VJlC+C)D;za*Ru( zh{xVJHr~W2Q^f~@{xT`8>W5|&MTni2B$zdssZS#HGNZ&OJ6k~oU%4pN1v>}l*tbiR z!i&9m1YL;1mH-yaXCC!75RFD7hylI@M28b>C(&qhor8MiqQH0Ob?*22m=1ahdITM` z61+-NH6`s?OPSDTK~LMWUX^D&c#A<#+g+(PEBisa@TuK|4VS4i$f*@BfKgrwdP+hK zfdxG&$Bscy%CQUSR43)waW3d-{|HS@X%KWNDJ`qi2hS>UllFMeohnBHzp$42ho;h{ zTYGg8^w=(%HJSb9Xg$uMwxf!GPg3^MeUhDSeUhoyvr5Hkk1cIbYEP?>ZuyDHxaK~I zzfm^Qt*Fbg%Xw0DOoqH@>xb|88g5LgA)kgY4R9!-SF8y=Is^*S~yp8%Z zxm$l9JnP>U)#K!gr36M$5QLDbh_NG@3W>RT@k(mVsJWD+X zI*3BeLbPWMgPqpql5OXLuK?{U2c?sX;gT=X+;Ee*)|(l7w1-It=YO`;Zc@ z^&wKNH+n6k-TX%#=D6)fjg?`<`z&%QSfcBUKAtLQHAu|$gVeGxW0`lFZ?zpz3;kcMd}V{c3rgn23gB~4XO5Pu&*7J z_}U464c3ccwvO_nzeaXF3B&bkPej2P7 z!A$8WJNjv4*UK8NPh-jpKaEJQ*VEWxw2-y1*Pj7=0<;$i{WGFm5v1YIU@_ofUrI;u zWnxzcdi@zvUifD~dcA%|iqyeqA^UFiWkgw;Kzs1fvhCVr_CXzWM!$^gdRN2sWkh-5 zm+|QJdKoBE&!L6v)z%+lLsjaa-F4J{j3~d;1*oIKWa7Vi{TNVQ_+vPFU)3#!b}yP$ z-L?f*rw?QMn&w)x65RzM>tV3&HfkS6l;7dv?kS3o(oqd$;?^I%K8zRq&u3d+v^;`c$;zhK>E)cyUx^Zd)vQ!w9jHnW8k38 zW7r5}`dP4EWMI`%cKTUl*K3FCvsm)N&mz(5^(?m43)M@c-hdXWDfX|BqLlS3qFfQ8 z;jdsRM5V-6b(8^_SS3cUze33i{|ZF!n|j)gmO2BPO+D?ZJo;4x^9CTayNB9W!Fo}P z)=@n4tH`c59Imed?}cB*q1WqGY!|A2mihu(m~Pt727D?C^s}+<8fu@7^)cD}wL5gXvy-?;nSj<`2ECSBZ)9ZaDQzv$0CoBe$Zs-wdfukdfiAB}$+ z|2F<(oa=v$KcBxFclz)7v+*b6uf{Jtn?D%;)x8D?-Xv*BKlIsThok0PPV{fr^uhV0 zYh%hi>2|yo;oj(W^e8%?QtL$CD zmPWAJ76j8n*_+@E)hsOQ1xF#2oN)Oo!)B~cNTSEcMDftqfK)az9~-I@wpXi8*xote za%U|+dDfB=`&Usr42B-44eS(GN4E3nHI;zSdv)dW*q^(j=<^ko^03S~sP3_^M|U4{ zuho;Jj+IJyN2+jDy^esa0T5`XJ(#F{wEQ2|hHKD=-Yfhi~&4h#u$IG4u%UL}RHa_T&A$ zQ(UGs_jUF!L(tO@QD@iWvFTM*!w&#LrxmpD2MF=bsV=tERNBX83g#dsDR;+;`m)j( z?YV1v?5&$fk`lV||L`dK{^d>ubQ=rire8Qubd`hbi6ux#<+`_c>Ws zL920P@0=R9zswZWG3Zb!D6tWuo->iJQsK+&|MBw;;4!T^s1#eZ;8XrGQ^cqxqQ{of zG7Y~$Hm^mW$b*5uj@kSCI{RC&mkd>TZ0M+{btKRuu%1Gl!skk>uX%v% zE7#ETJCS#&B-_>{NO^)ye9M7%?w(UPl&5zrmA~t-KnJM3@p~PO zwonPmt|S%orXC*Iupa{T>eFoRlwBQS$g1=QQOI7bF^!@Ul&#YE$`Kr~3<^G$sY+0! z{FM}DO+R>6w`ypQ*Kp9&X%&Pmw!T2CRzg(G30rL80YL|XZIwwdbq#I3Xf&#TaC_D> zUiFv<*9g_ufWTmj^vcdlk|Maaa8mHfaLWB6l~S>><)RM!*?DXI>sb~4&}yYN?Q6i_ z``%uvXv!k^m0Mp-vvMOyytL&Y#qqWtnnOZ#svlp`8%O~oEY`c7JY)4UG%)W9_ zC=2XVO4xP=U4_a5LZH=-j6$&Gn0%?R_&V*)Lo+j&Rn0xo8;p?PAr4de0I76!_n>H0 ziUanQi?*q-Q!Qj$E{eu?Rv^s!j);CLvmJwu78041$O6$9z-cy6UOQMDu8bPcdzqXF zTG%@v>SYvVa$;vYIKP)sR7jkiZmF^#MaNieyd1&)M{2(HTG9uk?l^>lU$p37HYleWz3ug!$9Z)N9Q3&><03r zC4kHTX?H!fc@q0a5KXj|9i1oT|24xkPnz<=c~YdeE>GHP0l#l#!na<5^!@0(DHEH4 zEJ?|qvOn70PHmRN{_z77ZN-;~4yUGFvm_}moFze8v!BQWq&?i+*jO{Po+s>MNxzTI zn;Nkf$dT~;kYzvGT~2L|#Qsr$CEDstCjOeHU2~);FPtMidXre`*$xNIx&BJ}e6*9Z zcE5zSR!fGIq|aC%?e3;FLt_8vL5a2sqcf!Zk7l@LNKjrlLvr-uWk~xKo%z;VNq>*d zGluOf`4JL6Af8OStEtV8*gtl_(^d`W{3!pQ8Ls)!lNZj98ohY=(f%Up=cTVl=XF8Z z3S>t|_I%i*-3_75j@Um2kVIQq(b-Y{H#1zbBPTDM9Wi?GvZMWoM)=lONk5Oy)7oHT z$&K#x2%ty1o2ku>*gpb*L|Z-4xl#TvGhB0{CNG>DEqd{Cqy2|O_|{WNACJx({=SkK z8Oh@zk9HSRn;Efx6g#4=km$@P|L+ag%!tVgXGV%%yv%67p%A|HVbZ^&^MswYmAt43 zo(_2Q7&baDV*eO+r>*RCUX=gI4A;D9$qVO2iC(%*>?Q&K}L*`r$C4D;j4v4mYf^Pi~RVo?LkUC;@ zw7ZqsjEMc?G68LcLuW+!U(9gLh?2Z;Mr7#4%ZT<18uP7xlKvc>*LB=e@*z7qVRW>+ zAhh`q`^T7wwz8q~q5L0axaLDhUN|2r^wt5%_FCijfk61yJ4s)T&WkB+DcMln9I!ds z-4EJqi2WlxL|eJg*--umGhDNwBQKl{5qghgLxDcH);CE%j?UX)Wk<<{gvj}lquuqO z&4t)MD($q@S|%Pcq1RmK?uB!qLGKY(hXBJ|NIx8XCp5IP`^&=~LnxV0kT}aYddei7 z39$+g>$DY#ObkQRYbIpmg)<>RFJ30JpNR+G`X%YV(Ro^O`$--I1Ww%MLHRw97=9TC zZ63r1@{$s>5N!n_6TfuPYaUeOh4Y|5Zyiv%heLjvN(bM1CF%3&yetT|lPm~YA}^c+0eX+*Kz#sktxuAE8=ZI5 zJ4=4xhr6#e+|_kHxw~qX-RLjVpv@1kOX!$o1hf@~Ow2<-ula!?FPtCv^B&0$@DX#Z zN0L4pod>+Imt;T&v_ZDf?q<+tK`bpD#?EeU~^=n-Nm5If7m~2_@S*LWa1Prdd+{LFP#7Md5`2j+w*5V zV6OFd>8sIs`On77etfLqutvLkL7V-sfAlgyTM08UjwiilKc8MW`{DB*QJs9b)*DGb zjlNTz>>T`MC?3Nkxla!@Mm0Kv0jR>TJW8PuqYN<+5u*$tC1X_`0t*lZL0}N%7!vaV z5`d7o5JW+U2nk}!5JHHE1R{Zm%$S0LCXE571UaeY*YY?1CK?I-lf)A?20d^<5x(8_ zp+ii2V&vOdK<;Xkjz5j$exR^P`o?Pe z+b>rlJZ%;dlmApl=!q#uEdR|`RH%4=aBEM9mY5Q)9*G*C10ck`aVcgiq}97h)pY>4 zGMh-Y+o&vB9GW%WxFrRJXONx!$KPhgVyb(TtF2N(_C;gtOQdT|)kXzi%>t-!3`ffH!OO;2hjfILwvL^vPLnWGiae@YM3L+5*pbR@!oo9V7>P7w2ArG0_&H

WHiyfT*fVjYF&y z1yNZyT&#c*A+|14X`3lsk5!=Va~xy~aF&9n5>)2`hPSRzc~Xi06cMkn- z@u*XIC{0X|u-zCBM#w^@a)7)doi6Rp+sdgtIauqpGAVyLGbE&7t9t5doI1c!N@Uup5CWQ!b5cDO*XkdXT-wq-xh5!6 zwCP2NpJGBT%&o3&e~)4;f#p_8QsY>4~@`m3U+wj_Yb z?{y1uUEfPoWCeZLF~*?GO%#SfbbxRywb&K3JiL?-A-Jh6M(Ug7r&WdfiafY2VJB{4wq1+>1uvANUDU0GsFB@8$PUKmXNm}-V}M$M+lwoz>tcT2l81cd3^gz*mIr&n~v zG-09j5Q(!3F}og;*>JS!LTzh`$WK|ya`V-5s{(Bz2;I+MV_0cfVmY~x^2WNhK5!Nzs|OeJnn88CU8n;Lx>nRL(^z5iLuNaztpfqT(Vii-if9QNk~S z_Px+Pa2m-(X{z>SvX0V}K<*?;u1Ua~Nw|&DXdN<-tI5i^Mo3)KaFM&PhB>-n(gWAO zI>pApqPv4E4vERTD3vLx*9SU4B!_9Gm`6XtyKoBEUov2AaN^Pr1=I-6A_KuM?kKos zi3}Hn^5x7EtRZ>=$J1GW#rHx4xK*-6P<{*un;617fvjEy?4?(~5V_HkvUsgWi9Dr) zjDGInJ>cL!`)QZj|Eyz7(3#!Fgdc?sb;s;mb&dOpuGIQKbt{ohk9lLj*5UM+1TPf%ALt zSWVw*hl#g_rNQ)t*GDLAz-O8@23-9%wpRdC6}6NWcO|}5;VMn}-Fe`Qe_FH?MG!g7 XO`@Tvg(7j-2D*u8iC)wKkRdD=ay}rg literal 0 HcmV?d00001 diff --git a/flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 b/flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 new file mode 100644 index 0000000000000000000000000000000000000000..5a4ae87e3c9f612d76942284dfbacda88e48dc04 GIT binary patch literal 7751 zcmV-N9=PE~Oix!vOfUce0000p9smFU0000ewJ-euSXJ==>b`wvN)Qm`U3EDVPVWG3 ze@mz$7biKyn{7u+hF@W5adW7#;KM^@7@Was8i z7~<_b`Tl$FEoBNj2B`*~1~G{z0lttSq~e}tF^*qHiFlXGYXJQk;Z5+J7wTEo9TyUi zJ|I$XFm;lwc;535hR9Uk-=8ue;cXLLelxJ62LnD9gC9N+cqsb<@iyj0wSX=u3NkqI z2F{5ds+BAkNburh_F)0B`$Keu8Tu$A#Oqqu3|381yWUI?y0h{oPb)d)43;sE$p;X` z?n~##fOPaV{C4n9$;PkeAI2Z{{!;w&TYR6g_`d$i-(pgeFU-c$(9UMesKHIDX~%4! zX|rZEeoQK`5XS+(5yeu)8!;4r&pcm?Z@g0EIOY$={eKU8>{2o2INmT+6*!I;ko;e7 z^I>|M&xBXo&K@@veN%k;|}_F_~#YHH{Sif9QH=|gA)9I9QJck@q=Uj9}fFjL-B(} z{{Iboq`COUj`UrQ$$b1j|9|=aWfUgBK=d^q58?=1|J+9B89H2KuDIxvKiV0?p7+e2 zNd7Km|2&{F94X0$Lq^BrZ$_f@?RZkKISadt2 z362>+4II}N#d^_ulBa=1l?@gIkOzy|l06!;*n-rD*Z9+Du)uxrz-S1nfZ26l|> z3{3b0A=ppcDI>Qv!cLzwU3vh+jwf>-h@N|Opx4q(=#zXMZoX0f{?iUW*YLm2-`~*T z=RlZOJ(rA3i>=kp$hJRjnpO*gTdSRsZGmcMWLvnIl{T%}t8Hhmc5Pp!(pq|z+VxY5 z-PYEoHL8tfr^@UV%8YK8Oz857t)W-xwa#9hcD;1k%o?h-MyFe8R5`V68;XT8pVDcz zZbGrsdiliKwG_K`)>Ef5+T3QJ%BWT9j9P8&6ndT3(e3tHXP-^GZbF^R`l^jawb7`p zv_`i~=5={(+RkO&oMx}JQ){(r=C%o~vDc}+v|6J|XY{GfYOl+t)tRlO+2^!&YO{82 zW3Aa*>$EnrOs1{0wM{8@dWAxt(PtgiHm$XDJGJX)_8P61R_!vXg=&@8=CbL0Mz2im zR9kDW&S|}EI_-MNw3qcWd(~c<*QxU9d|s{AELJv)8QC ziB(3m*{f8mwXIJnvxaJ+);hXu+O@51+I92kv^tqmrt*57+D_-zmOic4+PZ{JYpqji z*Gnz-T2Gd>=aPB7ZmnAE_4%x=(WSI*Zf(29HnrIrJ7s30SKC(F(d@Rl+-7ZCYS-CqR9Y{g zOuNQHZE3xIUa?oG^{Rw2pV%xminW{DY(0fCt@U-wv}>&uo2{o!8#|3|uS({XX>XxV z8>+?Hb(9H})=zEJuBT7tvUXmZSt)aAE1OWOcDuEiPN(%+GqYK14P9dGT04z8YwXmj zeL|N}Wj2b{Vwq2?){b6l=u&E}wN0m8Pp!*s-JCY1S=;(lrdi9-&bGCd>}pM`QC2oK zl$A{Vb6D!1!y5iM{0^W0^O$ocav9{vjgVu74>{CBJ&OCj@%|+=ux8~VsmlqrGnc{X z`v?EMI1>O2F%tm?3^cJK)@|7M{wR2pIB%55C5+cUtIrBRo=fqke3Sn4DF08 zS0$(vlla}=BC~vaTmCBe!GMp3uYtjmV76ccUYw2?9y$PF@+#ql>xCYxu0dm_qzH;~ zWM;w_3&n||T0n80PGtGaq-|(2%NVM1TOFJ{S6NiKJY&&8Koc;rkkXumhVfvfk`?_S zIIk?c@dxJ>6?e{Zv^FF0j&I|E-ARxf5uu@>Wx>mgCuj{WSR}mL9l(2%lQcVIP4SYa ziI9exAYyoz%bbON*#!Tn1FD-`#vVUv-wKLo5BP==Z^|d^7KNWzEj8S}xghrNMYHriGCtvue?j*{n60HM1sXSkcsy zXK6_oFKV2bX_GI^&d8EdTC*`S3W|{Wq;b*4w#f}mY7#q^c7x1nX8ko|L@-jDCY7ql z1i$gxoh%tc6yI3Kn5Otf3C6kjXpF>$6l0_|@*Ne;anQ!C9u&Ff^>lpusj5PU8+exR zp(Daj69MSP;7V|i#k)NFWI>AyJkV05Ko`Vn^}*X6f_J+hwV{;+;2;<#*#<3XEzXLA z+gP9n$nd<5)JC>DzHPULPVvEj{08wjkklnZ;5m~8M3g3XG)O{ZmVl3li7P&B$_i5% z{8oq{c7I5hU>3V&*Xqv`Fe}ntY%`!@Cu9N*8n96h3CR*Z1YouDa3qWYZv@Q^d&DkWrHFwGES&UK z?4^ccaK?c!i!8}LR3Zb`^8hOXP(j4O2OVP9#g9i73IBLhk=!2-`h^-neydWL;O`U* z;xB`~A`FB;LdXDEg`|+3d1#Y+Qhk@#ND-#OTn?Cv?h%0&4HQGbxOkU~+@<3oyb0JQ zEw2XvD@0xp7gRDIE#k;H!}IzJ)sb&VZ@>MGC&aWICcx0Bc@Y{SVF{264I+Ga_mkde zHt+#RuJW9yC3jeuLNJAQc??+T-9dlnz7zb3&Eh*Jeq$jFW;O5!**uGHJW_n;{JsVd z55W0Z z4}LgK>QfpQa&&htgufZzetTm4C%nWrptv zGA(nXkoSarHUhp~p5wR6Ll!<@q#Xo<^QaqgQ?6DBg$2w4IS&?oR3s(#;~eBP4b}xRUTL&&IG? z&TnN#paElmofA!tEn42Klcnllz%!zP5nVH!GVNd0*&S~Z4VgfSZ0tUVZ7E06S_CVYuvSAvC%;U>eY(|5y14s-Z<*O3@&UFvq)2a+KyuIJqY5$bFl11&-qdB3FHn zU+RMK?F(g2QFlaCK!#4%h6c0{p|h7Ip_d7Ctp z-okn#;l~BnCtzG~s-|2*EQUSrr+OlpcRdTiz@}NzuwgwcQY?JNoQP1z;_6xiq^1MJ zFiJKO=A|*dou;cyvnvqN1|ztrK#Ajrj+1kc8{XwK(s+n-M?jYqR~;Q10Z_p;RmHo` zH?AVNZ)`-u_%;b{6@c+2GzHb;!D|g%l^((=IEYs8F3*xMGqfee9hQ%`1p@8qa^waS z?{;}(+UTqRq?fs?7Xi9GA_iU4Y)L7A=k+%hnt^|4$Mi~IU;02_fs?amMT{*eZ^j6A z44~FE;@$7E%TNahR!SE1%ZlLL8UWxW;$5dVrnwS+3T$KA8+Tbsh{SDtrh}4UyFFx>4l>C6XwI`l@b^uh%`DRd>ij3{u0QIVBp0_nX=8R42c{aJAjPM z^RCe$JUPrTIU|Bl5ef>JL4=k;z9BBa7&Mm)zR@8*YfoedaKeuQg|_SrkYeQLj^BN* z0+C>l-UjnsWU2)y0l-(F=!hL=Ok~{|tAktK?v* z2)`2yoZ;W|8i!FaSHeIt)FXGU(lp0GKTrc8GO+4IY zO~K*_@5aX$d00s_d$v9hx!?j0DlNkqyjw>UMkT|@Y~Voy2~bssupOWYPWat1(D}jn z5p#-e?{^_^j1zY`6BIuQ5IBeg+#E#+RQY0~z_4zF4QAC4JG7j^33j;@{lK6~1!_WT zmJ!xsyl#D}Qi{|~kwIkc>qirlBsU^nRXS-xA;Ah9RS=k6;2=1G&Wc}zAl|syFmt8s z3Qixl9BM_rkXH;`i6D`iCNX5)$Ac|TQG#$?a3pF*gwPTSK#1ey3J6A!US-<$K!L)b z2$e$2E}bZFkh4k!9OSHCfMqaMBo{acs6iL`SH*RFo9mYdc2ePmj6-APz}y`aF5;m0 zu;SepiYw0oNJmbt;9QA&2yU8$e|VRI7wRcy%bd7H*eOY{qMYW?d0@lu{zCb(#<#OS zTo``|j4!x=vq5y=6^8^*P;xA;g6JC>W^m$nWH-oyzzH3eLwITwBPNXjUz7 zX%(~zAJ);T_{(5#QHq<87L`|Dz>APK`=dA#tSsB{325B_sG9xuzW`5@r? zoB4mP+1Xf{HSNsI%sPA`=bxPU+@C)LY(fa}|L@_yUh7G`*I(dR-}PaSmnL5Ujs4BvurO<*ZU6v7FTlaMD{9X`}((LSoV1WJoby(vt6}n2ZFU7$1S@ zb(w=dVNonab20cAJ&kF+j((E_pV#raj&$=L9E8-FA#`+qq+m_0U`l=DZoEg2sxw|-Si@uZl0GGeUCqit@t*Z zk0M82M{_awJ%e@R^1AzdRGP*h*nLzZr2Bg;)B(Sp29M@j@PA-em1(aU+D9hoHc@=r z#2v8XS1$qjjl%p5_4xJ@U{a~lUN!yxsR(|r3ZOaZZee`81)2&YXX5p@97?S(e`QZy_j=k<4jgsl;&udmKkdW!< zp99D6qCUZeyUTPi()|L%tLR@O^p?HE9)s?~-U03&V%RPIE$5Ymx8reMQNc|(j6Otu zxH!H9j^QS67^*(P{Qf_Qyk9b?_%;0V_AUHy0_=qJ7I@P8AeM@~hEfNiH5((Vz8Q?K z(4RnxOmE8|Qwd(8RTPs{@xF zW*Jh9iEHqmud*cwdlG`gwRno$^P+R#Qad!aH>I;q2#Zi3e8?0_8+1*%=d88r~H@HFOje-ip?sC&+ zqYOKOoheBWiFRX{=~p>LUvH;28fh-rU$#eLa0|H)iFO}(p}X(Kj0|+sNgX+L-%Y|E z63Knv#3eBj?f%uFyHDCp9N#wK1+4gBz&MIm9EFAf5YK?9N0`Q=BDrxC+DK@`)Ft@w z93VMZB_H~gMSo?^D%G#dS-k-ByNcxg%IM+SEx zzZIBRO0JwY`47O?qBtD=!niVWIULR9c+-27_wSLLOa;dH zHW(aaj7b53p{Su8$dEHeL}WxnL`0HA9U%q85P?C;7zaNRpm2E#14#@a0^&es2qA_L zA`+3v42q1xYB=W|GD-oi`OtKiqH95vMR-KY9c1M!zpfYXdv7{C2y!eYZRl+BJI@kJ z7sStW*#1GmnBuDf({S4-);zb|!Y&407wTf!Y?%{=pw`{xPWu5v46unpY@uj& zMe9uo_jHBUi%Ih7uw~0cF2#%iZA{berDnu)RqKpCSG#EqBxhk{wv6bVSG4YYr@ zh=s>t2b>U&xe^@$McH~bIdWiS0b(mnKbqfcbo5i$AkqXLA6pe7LSJL}!4;e#rs@79 z+4B410;#3{=p-GPr)UY)o=Sdip1$EK0a{o@hk9%`j?t>5?RCi&VXt76KHP^!M`{EZU%92>Ij1CR3yk72I6~s%Eq@^(l{}qj}<*-4PRM1 zm_xn1;p^YEd>yr34Gx~U<#u1XrwW9DZ70T|gNF)5*{{5Xq4@i7C`wQi&IQFrh{6JR zD6H)hH~$NjA{Ng?*=H;8RekS^FN{x#-W`1to(Xs)4v+s&5Of^iF*W~++3%3^HiFn> zX8D(Nin2Kxp=i^*ou{MCiR54uZ%-GN#qM*dn}uf585aR5iLOCNGHI;-nhslJREQxuM{^=kyIZ=}#hsyV%gMdqiTlPj@o6N&RXef0RtSk=H<6mcGO1789DvB7 zUCVE?P(NX_=iDS}j^T)1MCUg2ot6)w%FT#p(qinktv%af5w+zsPQ$4dN}PYQ3RmPiT3 zX>54j1PBrfII+*PXF%)itC9ul>d8sknLZ*yPyLT!v+iB+Hk0#n_JyfYwf(3Y(By3$ zmyKB$$~7gXeTwd0?yyjmhIvia-ys8GqAy?wyjk@wLFr2~MSonGdtG9s^DK60JKdPE zG74W8`~|gU%~Jy9A)^tY2U~3--GV@UlsW?|XP41^qor3~+VBV0R1GTW2cm^IWx{)r zKb-fM@4E6j3_}cfxmBn<4#`y!6Y@2$DLti1KkoLw*%JdV2vy#J@91eyNE6dQhnPzA N-Rj;Wrx2(a))JoU;K2X@ literal 0 HcmV?d00001 diff --git a/flow-ergonomics-guide.md b/flow-ergonomics-guide.md new file mode 100644 index 00000000..d90cf4d9 --- /dev/null +++ b/flow-ergonomics-guide.md @@ -0,0 +1,117 @@ +# Flow drag-and-drop ergonomics test (run straight through) + +A fixed battery of drag targets, paired with drag telemetry, to turn "a lot of moves I couldn't get +across" into specific, located problems. + +## 1. Setup + +Launch with logging on and the fixture open: + +```fish +rm -f ~/flow-drag-log.jsonl; env FLOWSTATE_DRAG_LOG=$HOME/flow-drag-log.jsonl cargo run --release -p flowstate --manifest-path /home/adam/Projects/flowstate-fixflow-collab/Cargo.toml -- /home/adam/Projects/flowstate-fixflow-collab/flow-ergonomics-fixture.fl0 +``` + +Each **drag** appends one JSON line to `~/flow-drag-log.jsonl`. Send me that file when done. + +## 2. The reset protocol (this is what lets you run straight through) + +Every step below starts from the **fresh** layout in the map. To get back there after each step: + +> **After each step, press the ribbon's `Undo` button once per move you made this step**, until the +> board matches the map again. (Do NOT save.) + +- A move that "didn't take" (no change) has nothing to undo — you're already fresh. +- If you attempted a step several times, Undo that many times. +- If the board ever looks wrong or Undo won't go further, **hard-reset**: close the tab and reopen + `flow-ergonomics-fixture.fl0` (don't save first). Telemetry keeps logging across reopens. + +Undo is *not* part of the test — only your drags are logged, so undoing between steps costs us nothing. + +## 3. Board map — the "Ergonomics" sheet (every step starts here) + +Columns left → right: **1AC · 1NC · 2AC · Block · 1AR · 2NR · 2AR** + +``` +1AC 1NC 2AC Block 1AR 2NR +A1 ──┬── B1 ─────── C1 ─────── D1 ──── E1 ── F1 + └── B2 +A2 ───── B3 ──┬─── C2 + └─── C3 +A3 ───── B5 + B4 (orphan, no parent) + C4 (orphan, no parent) +``` + +- Chain: `A1→B1→C1→D1→E1→F1`. Also `A2→B3→{C2,C3}` and `A3→B5`. +- `B4` (in 1NC) and `C4` (in 2AC) are parentless orphans. +- Heights vary on purpose. There's a throwaway second sheet **Scratch** if you want extra room. + +For each step: **Move** = what to do · **Expect** = where it lands (from the fresh map) · **Watch** = +the feel to judge. Then **↩ reset** and go to the next. + +## 4. The battery + +1. **Reorder siblings.** Move **B2** above **B1**---feilt weird + *Expect:* under A1, order becomes B2, B1 (both still 1NC responses to A1). *Watch:* landing "just + above B1" precisely. **↩ Undo once.** + +2. **Reorder roots.** Move **A3** to the very top of 1AC (above A1). + *Expect:* 1AC reads A3, A1, A2. *Watch:* dropping at the very top of a column. **↩ Undo once.** + +3. **Demote a root to a child (rightward).** Drop **A2** onto **A1** as its last response.---EXTREMELY DIFFICULT AND NARROW + *Expect:* A2 becomes A1's last 1NC child; its subtree B3→2AC, C2/C3→Block. *Watch:* the right-edge + "make child" zone — findable? triggers when you mean it? **↩ Undo once.** + +4. **First-child insert.** Make **A3** the **first** response of **A1**.---did something weird, moved entire childset down a lot, no natural gap between it and second child + *Expect:* A3 is A1's first 1NC child, above B1/B2. *Watch:* first vs last child. **↩ Undo once.** + +5. **Promote a child to a root (leftward).** Move **C1** into 1AC, dropped between A1 and A2. + *Expect:* C1 root in 1AC between A1 and A2; D1→1NC, E1→2AC, F1→Block (chain preserved). *Watch:* can + you place it at the vertical spot you aim at? **↩ Undo once.** + +6. **Reparent a subtree.** Move **B3** (carrying C2/C3) to be a response to **A1**. + *Expect:* B3 becomes A1's child; C2/C3 stay B3's children in 2AC. *Watch:* subtree stays intact and + follows the pointer. **↩ Undo once.** + +7. **Insert into the middle of a sibling run.** Move **C4** (orphan) to sit **between C2 and C3** under + B3. + *Expect:* 2AC under B3 reads C2, C4, C3 (C4 now B3's child). *Watch:* precise mid-run insertion. + **↩ Undo once.** + +8. **Orphan → response.** Move **B4** (orphan) to be a response to **A1**, above B2. + *Expect:* B4 becomes a 1NC child of A1, above B2 (it should *inherit* A1 as parent). *Watch:* does + dropping next to a parented cell adopt that parent? **↩ Undo once.** + +9. **Reach the far-right column.** Move **F1** to be a standalone root in the last column, **2AR**. + *Expect:* F1 is a parentless root in 2AR. *Watch:* can you target/reach the rightmost column? + **↩ Undo once.** + +10. **Standalone at top of a column.** Move **C3** to the very top of 2AC as a standalone (no parent).---weird, same drop and no gap effect as earlier + *Expect:* C3 is parentless at the top of 2AC. *Watch:* standalone drop vs auto-adopting a parent. + **↩ Undo once.** + +11. **First-child of a subtree, multi-level shift.** Make **C1** (with its chain) the **first**---same weirdness + response of **A2**. + *Expect:* C1→1NC (first child of A2), D1→2AC, E1→Block, F1→1AR. *Watch:* the whole chain shifting + columns together. **↩ Undo once.** + +12. **Invalid move (should refuse).** Try to drop **A1** onto its own descendant (**B1** or **C1**) as + a child. + *Expect:* **nothing** — no landing preview, no move. *Watch:* does it feel clearly "not allowed"? + **↩ Nothing to undo** (it shouldn't have moved). + +13. **Long move + auto-scroll.** Scroll so the top-left and bottom-right aren't both visible, then drag + **F1** all the way to above **A1**. + *Expect:* F1 becomes a root in 1AC above A1. *Watch:* does edge auto-scroll carry you there? + **↩ Undo once** (scroll back up first if needed). + +14. **Free-for-all.** From fresh, do 3–5 moves that *should* be easy — especially any that beat you + last time. Attempt each; the log records every attempt and where the pointer actually was. Undo + between them if you like.---woah B3 child of B2 caused C1 child of B1 to go BELOW B3? That's not the hierarchy at all... + +## 5. Sending results + +Send me `~/flow-drag-log.jsonl`. A one-line note per step number where the feel was off ("#5 still +snaps", "#9 couldn't reach 2AR") plus the offsets makes the diagnosis fast. Anything you'd rather +show than describe, type into the relevant cell and save a copy — I can read notes straight out of the +`.fl0`. From 3bb90094f082f2b9c7b5e9728ee6c98bb925253f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:48:03 +0000 Subject: [PATCH 14/24] flowstate-document: standalone-flow materializer for .fl0 cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parameterize paragraph_ids_by_boundary over its registry (paragraph_ids_by_boundary_in) and add materialize_single_flow — the body flow-walk law applied to one ensure_flow-shaped map carrying its own paragraphs_by_id child, returning RegionRows with block ids derived 1:1 from paragraph ids. Expose ensure_flow/ensure_sentinel so the .fl0 schema can seed cell flows with the identical canonical shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-document/src/lib.rs | 8 +- .../flowstate-document/src/loro_projection.rs | 227 +++++++++++++++++- crates/flowstate-document/src/loro_schema.rs | 4 +- 3 files changed, 232 insertions(+), 7 deletions(-) diff --git a/crates/flowstate-document/src/lib.rs b/crates/flowstate-document/src/lib.rs index 76708a15..553b8d27 100644 --- a/crates/flowstate-document/src/lib.rs +++ b/crates/flowstate-document/src/lib.rs @@ -13,8 +13,9 @@ pub use loro_import::{ ImportedLoroDocument, document_to_loro, import_document_projection, import_paragraphs_as_loro, write_imported_document_as_loro_db8, }; pub use loro_projection::{ - ChangedContainerOwner, RegionRows, document_from_loro, document_from_loro_with_defects, materialize_body_region, materialize_table_block, - object_input_blocks_for_ids, object_input_blocks_from_loro, owner_of_changed_container, section_page_attrs, + ChangedContainerOwner, RegionRows, document_from_loro, document_from_loro_with_defects, materialize_body_region, materialize_single_flow, + materialize_table_block, object_input_blocks_for_ids, object_input_blocks_from_loro, owner_of_changed_container, paragraph_ids_by_boundary_in, + section_page_attrs, }; pub use loro_schema::{ BLOCKS_BY_ID, BODY_FLOW_ID, COMMENTS_BY_ID, FLOW_ATTRS_KEY, FLOW_ID_KEY, FLOW_KIND_KEY, FLOW_TEXT_KEY, FLOWS_BY_ID, MAIN_BODY_BLOCK_ID, @@ -25,7 +26,8 @@ pub use loro_schema::{ SECTION_ATTR_PAGE_NUMBERING_START, SECTION_ATTR_PAGE_WIDTH, SECTIONS_BY_ID, SENTINEL_NEWLINE, SectionMargins, SectionOrientation, SectionPageAttrs, SectionPageNumbering, SectionPageSize, TABLE_CELLS_BY_ID, TABLE_COLUMN_ORDER, TABLE_COLUMNS_BY_ID, TABLE_KEY, TABLE_ROW_ORDER, TABLE_ROWS_BY_ID, USERS_BY_ID, cell_flow_loro_id, cell_loro_id, cell_loro_id_for, column_loro_id, document_id, - document_schema_version, ensure_section, fork_document_lineage, init_loro_document, new_loro_document, parse_cell_loro_id, + document_schema_version, ensure_flow, ensure_section, ensure_sentinel, fork_document_lineage, init_loro_document, new_loro_document, + parse_cell_loro_id, parse_column_loro_id, parse_row_loro_id, read_section_page_attrs, record_revision, register_replica, register_user, row_loro_id, set_section_page_attrs, touch_document_metadata, }; diff --git a/crates/flowstate-document/src/loro_projection.rs b/crates/flowstate-document/src/loro_projection.rs index 1219947e..16d79c4e 100644 --- a/crates/flowstate-document/src/loro_projection.rs +++ b/crates/flowstate-document/src/loro_projection.rs @@ -150,6 +150,71 @@ pub fn materialize_viewport(doc: &LoroDoc, start_unicode: usize, end_unicode: us materialize_body_region(doc, sentinel, end, ¶graph_map, &pblock_map, &object_map) } +/// Materialize ONE standalone flow — a flow map shaped by the canonical +/// `ensure_flow` (sentinel text + `MARK_*` styles) that carries its OWN +/// `paragraphs_by_id` registry as a child, e.g. a .fl0 cell's rich text. Runs +/// the SAME flow walk as the body materialization, so coalescing, style +/// defaults, fabrication, and defect reporting are one law. Paragraph-only: +/// there is no object registry, so a stray U+FFFC is skipped exactly like the +/// .db8 table-cell walk; block ids are derived 1:1 from the paragraph ids (a +/// standalone flow's blocks ARE its paragraphs). `flow_id` labels defects and +/// must not be the body flow id. +pub fn materialize_single_flow(doc: &LoroDoc, flow_map: &LoroMap, flow_id: &str) -> io::Result { + debug_assert_ne!(flow_id, ROOT_BODY_FLOW_ID, "standalone flow materialization is not the body path"); + // §28 shape: prefer direct resolution via the flow's stored raw container id, + // falling back to map-key traversal (same law as `Projector::flow_text`). + let text = match map_string_opt(flow_map, "text_container_id")?.and_then(|id| resolve_text_by_container_id(doc, &id)) { + Some(text) => text, + None => child_text(flow_map, FLOW_TEXT_KEY)?.ok_or_else(|| invalid(format!("flow `{flow_id}` has no text")))?, + }; + let paragraph_index = match child_map(flow_map, PARAGRAPHS_BY_ID)? { + Some(paragraphs) => paragraph_ids_by_boundary_in(doc, ¶graphs, &text), + None => FxHashMap::default(), + }; + let delta = crate::streaming_delta::streaming_to_delta(&text); + let mut blocks = Vec::new(); + let mut paragraph_ids = Vec::new(); + let mut defects = Vec::new(); + let no_objects = BTreeMap::new(); + Projector::walk_flow_delta( + &text, + delta, + 0, + &no_objects, + flow_id, + Some(¶graph_index), + None, + Some(&mut paragraph_ids), + None, + &mut blocks, + &mut defects, + false, + |_, _| Err(invalid("standalone flow has no object blocks")), + )?; + if paragraph_ids.is_empty() { + // Same deterministic empty-projection placeholder as the body path: the + // editor's document assembly would otherwise mint a RANDOM id for the + // mandatory trailing paragraph, so two rebuilds of the same flow disagreed. + blocks.push(InputBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + })); + defects.push(ProjectionDefect::MissingParagraphMetadata { + flow_id: flow_id.to_string(), + boundary_unicode: None, + fabricated_id: loro_id_u128("paragraph.projection.empty"), + }); + paragraph_ids.push(ParagraphId(loro_id_u128("paragraph.projection.empty"))); + } + let block_ids = paragraph_ids.iter().map(|id| BlockId(id.0)).collect(); + Ok(RegionRows { + blocks, + paragraph_ids, + block_ids, + defects, + }) +} + /// §act-four M4 (cold scroll): the body-unicode position of every block's /// leading boundary, in block order — paragraph leading `\n`s plus object /// U+FFFC placeholders, sorted + deduped. Persisted in the package manifest so @@ -1388,11 +1453,21 @@ fn push_paragraph_projection_metadata( /// except boundary 0 always prefers `ROOT_FIRST_PARAGRAPH_ID` when it anchors there. #[hotpath::measure] fn paragraph_ids_by_boundary(doc: &LoroDoc, text: &LoroText) -> FxHashMap { - let mut index: FxHashMap = FxHashMap::default(); let root = doc.get_map(ROOT); let Some(paragraphs) = child_map(&root, PARAGRAPHS_BY_ID).ok().flatten() else { - return index; + return FxHashMap::default(); }; + paragraph_ids_by_boundary_in(doc, ¶graphs, text) +} + +/// [`paragraph_ids_by_boundary`], parameterized over the paragraph registry: the +/// same single-pass boundary→record-key index for a flow whose paragraph records +/// live in `paragraphs` instead of the document-root `PARAGRAPHS_BY_ID` — e.g. a +/// .fl0 cell's per-cell registry. Cursor decoding already filters on `text`'s +/// container id, so foreign records in a shared registry are skipped either way. +#[hotpath::measure] +pub fn paragraph_ids_by_boundary_in(doc: &LoroDoc, paragraphs: &LoroMap, text: &LoroText) -> FxHashMap { + let mut index: FxHashMap = FxHashMap::default(); // §act-five P4: ONE pass over the paragraph registry. The old shape scanned it // TWICE (once inside `boundary_cursor_positions`, once in the loop) and decoded // each cursor twice (there, then again in `live_cursor_pos`). Gather the decoded @@ -2348,4 +2423,152 @@ mod tests { ); Ok(()) } + + /// Seed a STANDALONE flow (the .fl0 cell shape): canonical `ensure_flow` map + /// inside `registry`, sentinel + first paragraph record in the flow's OWN + /// `paragraphs_by_id` child — the exact shape `materialize_single_flow` reads. + fn seed_standalone_flow(registry: &LoroMap, flow_id: &str) -> (LoroMap, LoroText, LoroMap) { + let flow = crate::loro_schema::ensure_flow(registry, flow_id, "flow-cell").expect("ensure flow"); + let text = flow + .ensure_mergeable_text(crate::loro_schema::FLOW_TEXT_KEY) + .expect("flow text"); + crate::loro_schema::ensure_sentinel(&text).expect("sentinel"); + let paragraphs = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID).expect("paragraph registry"); + standalone_paragraph_record(¶graphs, &text, ROOT_FIRST_PARAGRAPH_ID, flow_id, 0); + (flow, text, paragraphs) + } + + fn standalone_paragraph_record(paragraphs: &LoroMap, text: &LoroText, key: &str, flow_id: &str, boundary: usize) { + let record = paragraphs.ensure_mergeable_map(key).expect("paragraph record"); + record.insert("id", key).expect("record id"); + record.insert("flow_id", flow_id).expect("record flow id"); + let cursor = text.get_cursor(boundary, Side::Left).expect("boundary cursor"); + record.insert("boundary_cursor", cursor.encode()).expect("boundary cursor bytes"); + record.insert("start_cursor", cursor.encode()).expect("start cursor bytes"); + } + + /// The .fl0 cell round trip: a standalone flow with styled multi-paragraph + /// content materializes to the exact paragraph rows, styles, run marks, and + /// durable identities — deterministically across rebuilds. + #[test] + fn materialize_single_flow_round_trips_styled_cell_text() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let flow_id = "cell.7.flow"; + let (flow, text, paragraphs) = seed_standalone_flow(®istry, flow_id); + + // "\n" (sentinel, Normal) + "Hello wörld" + "\n" (Custom(3) tag row) + "café tail". + text.insert(1, "Hello wörld").expect("insert first paragraph"); + let boundary = text.len_unicode(); + text.insert(boundary, "\n").expect("insert boundary"); + text + .mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, 4_i64) + .expect("mark boundary style"); // slot value 4 → Custom(3) + standalone_paragraph_record(¶graphs, &text, "paragraph.row2", flow_id, boundary); + text.insert(boundary + 1, "café tail").expect("insert second paragraph"); + // Strikethrough over "wörld" (unicode 7..12) — the strike_cell shape. + text + .mark(7..12, MARK_STRIKETHROUGH, true) + .expect("mark strikethrough"); + doc.commit(); + + let rows = materialize_single_flow(&doc, &flow, flow_id)?; + assert_eq!(rows.defects, Vec::new(), "well-formed flow materializes without defects"); + assert_eq!(rows.blocks.len(), 2, "two paragraph rows"); + let InputBlock::Paragraph(first) = &rows.blocks[0] else { + panic!("first row is a paragraph"); + }; + assert_eq!(first.style, gpui_flowtext::ParagraphStyle::Normal); + assert_eq!( + first.runs.iter().map(|run| run.text.as_str()).collect::(), + "Hello wörld" + ); + assert!( + first + .runs + .iter() + .any(|run| run.styles.strikethrough && run.text == "wörld"), + "strikethrough mark survives the round trip" + ); + let InputBlock::Paragraph(second) = &rows.blocks[1] else { + panic!("second row is a paragraph"); + }; + assert_eq!(second.style, gpui_flowtext::ParagraphStyle::Custom(3)); + assert_eq!(second.runs.iter().map(|run| run.text.as_str()).collect::(), "café tail"); + // Durable identities: the seeded first record + the explicit second record, + // and block ids derived 1:1 from them. + assert_eq!( + rows.paragraph_ids, + vec![ + ParagraphId(loro_id_u128(ROOT_FIRST_PARAGRAPH_ID)), + ParagraphId(loro_id_u128("paragraph.row2")), + ] + ); + assert_eq!(rows.block_ids, vec![BlockId(rows.paragraph_ids[0].0), BlockId(rows.paragraph_ids[1].0)]); + + // Pure function: a second materialization is identical. + let again = materialize_single_flow(&doc, &flow, flow_id)?; + assert_eq!(again.blocks, rows.blocks); + assert_eq!(again.paragraph_ids, rows.paragraph_ids); + assert_eq!(again.block_ids, rows.block_ids); + + // And the rows assemble into a full per-cell DocumentProjection. + let projection = document_from_input_blocks(DocumentTheme::clone(&flowstate_document_theme()), rows.blocks); + assert_eq!(projection.paragraphs.len(), 2); + Ok(()) + } + + /// An EMPTY (even unseeded) standalone flow materializes the deterministic + /// placeholder row with a fabricated identity + defect — never a random id. + #[test] + fn materialize_single_flow_empty_flow_is_deterministic() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let flow = crate::loro_schema::ensure_flow(®istry, "cell.9.flow", "flow-cell").expect("ensure flow"); + doc.commit(); + + let rows = materialize_single_flow(&doc, &flow, "cell.9.flow")?; + assert_eq!(rows.blocks.len(), 1, "placeholder paragraph"); + assert!(matches!(&rows.blocks[0], InputBlock::Paragraph(paragraph) if paragraph.runs.is_empty())); + assert_eq!(rows.paragraph_ids, vec![ParagraphId(loro_id_u128("paragraph.projection.empty"))]); + assert_eq!(rows.block_ids, vec![BlockId(loro_id_u128("paragraph.projection.empty"))]); + assert!( + rows + .defects + .iter() + .any(|defect| matches!(defect, ProjectionDefect::MissingParagraphMetadata { .. })) + ); + let again = materialize_single_flow(&doc, &flow, "cell.9.flow")?; + assert_eq!(again.blocks, rows.blocks); + assert_eq!(again.paragraph_ids, rows.paragraph_ids); + Ok(()) + } + + /// Registry parameterization: `paragraph_ids_by_boundary_in` reads ONLY the + /// given registry, and cursor container filtering keeps two standalone flows' + /// records from cross-talking even in a shared registry. + #[test] + fn paragraph_ids_by_boundary_in_scopes_to_the_given_registry() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let (_flow_a, text_a, paragraphs_a) = seed_standalone_flow(®istry, "cell.a.flow"); + let (_flow_b, text_b, paragraphs_b) = seed_standalone_flow(®istry, "cell.b.flow"); + text_a.insert(1, "alpha").expect("insert a"); + text_b.insert(1, "beta").expect("insert b"); + // A record for flow B written into flow A's registry: its cursor targets + // B's container, so A's index must skip it. + standalone_paragraph_record(¶graphs_a, &text_b, "paragraph.foreign", "cell.b.flow", 0); + doc.commit(); + + let index_a = paragraph_ids_by_boundary_in(&doc, ¶graphs_a, &text_a); + assert_eq!(index_a.len(), 1); + assert_eq!(index_a.get(&0).map(String::as_str), Some(ROOT_FIRST_PARAGRAPH_ID)); + let index_b = paragraph_ids_by_boundary_in(&doc, ¶graphs_b, &text_b); + assert_eq!(index_b.len(), 1); + assert_eq!(index_b.get(&0).map(String::as_str), Some(ROOT_FIRST_PARAGRAPH_ID)); + Ok(()) + } } diff --git a/crates/flowstate-document/src/loro_schema.rs b/crates/flowstate-document/src/loro_schema.rs index 7f4c188a..6e62f2df 100644 --- a/crates/flowstate-document/src/loro_schema.rs +++ b/crates/flowstate-document/src/loro_schema.rs @@ -700,7 +700,7 @@ fn init_meta(meta: &LoroMap, title: &str) -> LoroResult<()> { Ok(()) } -fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { +pub fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { let flow = flows.ensure_mergeable_map(flow_id)?; flow.insert(FLOW_ID_KEY, flow_id)?; flow.insert(FLOW_KIND_KEY, kind)?; @@ -710,7 +710,7 @@ fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult LoroResult<()> { +pub fn ensure_sentinel(text: &LoroText) -> LoroResult<()> { if text.len_unicode() == 0 || !text.to_string().starts_with(SENTINEL_NEWLINE) { text.insert(0, SENTINEL_NEWLINE)?; text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; From 55e3da8caad9a924b014a8e81028c936f900bc44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:05:11 +0000 Subject: [PATCH 15/24] flowstate-flow: .fl0 v2 Loro schema, total materializer, normalization law MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CRDT-first modules alongside the record-era API (deleted when the editor rewires): format.rs (immutable format vocabulary), loro_schema.rs (flow.meta/sheet_order/sheets_by_id/cells_by_id/annotations containers; MovableList ordering; per-cell .db8-shaped flows with own paragraph registries; seed/replace/mark write helpers), loro_projection.rs (total board materializer with deterministic normalization: liveness, column clamp, dangling-parent orphan, greatest-uuid cycle break, adjacency orphan, canonical DFS re-linearization; CellSummary law), projection.rs (FlowBoardProjection/Sheet/Cell/CellSummary), board_ops.rs (pure drag preview/commit resolution law), intents.rs (FlowIntent vocabulary), FLOWFL0 v2 container with explicit v1 rejection. Gates: import→materialize identity, snapshot round trip, 3-peer raw- mutation determinism fuzz, cycle/orphan/run-split laws, and the two headline defects asserted dead at schema level (reorder-vs-edit, same-cell char merge). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-flow/Cargo.toml | 2 + crates/flowstate-flow/src/board_ops.rs | 327 ++++++++++++ crates/flowstate-flow/src/document.rs | 125 +---- crates/flowstate-flow/src/format.rs | 129 +++++ crates/flowstate-flow/src/intents.rs | 153 ++++++ crates/flowstate-flow/src/lib.rs | 17 +- crates/flowstate-flow/src/loro_projection.rs | 504 ++++++++++++++++++ crates/flowstate-flow/src/loro_schema.rs | 514 +++++++++++++++++++ crates/flowstate-flow/src/operations.rs | 16 +- crates/flowstate-flow/src/persistence.rs | 85 +++ crates/flowstate-flow/src/projection.rs | 93 ++++ crates/flowstate-flow/tests/v2_board.rs | 435 ++++++++++++++++ 12 files changed, 2262 insertions(+), 138 deletions(-) create mode 100644 crates/flowstate-flow/src/board_ops.rs create mode 100644 crates/flowstate-flow/src/format.rs create mode 100644 crates/flowstate-flow/src/intents.rs create mode 100644 crates/flowstate-flow/src/loro_projection.rs create mode 100644 crates/flowstate-flow/src/loro_schema.rs create mode 100644 crates/flowstate-flow/src/projection.rs create mode 100644 crates/flowstate-flow/tests/v2_board.rs diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index e6e71cff..cdc5345b 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -10,8 +10,10 @@ workspace = true [dependencies] anyhow = { workspace = true } flowstate-document.workspace = true +gpui-flowtext = { workspace = true } loro = "1.13.1" postcard = "1.1.3" +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } uuid = { workspace = true, features = ["serde", "v4"] } zstd.workspace = true diff --git a/crates/flowstate-flow/src/board_ops.rs b/crates/flowstate-flow/src/board_ops.rs new file mode 100644 index 00000000..d1508501 --- /dev/null +++ b/crates/flowstate-flow/src/board_ops.rs @@ -0,0 +1,327 @@ +//! Pure board operations over [`FlowBoardProjection`] (.fl0 v2 spec, Part A). +//! +//! Every function here is a pure function of projection data — no Loro, no +//! gate — so the drag preview can run one every frame, and the committing +//! runtime resolves the SAME functions inside the gate (one law for preview +//! and commit). Ported verbatim from the record-era `operations.rs`. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context as _, bail}; +use uuid::Uuid; + +use crate::format::{CellId, SheetId}; +use crate::intents::FlowDropIntent; +use crate::projection::{Cell, FlowBoardProjection, Sheet}; + +pub fn is_descendant_of(sheet: &Sheet, cell_id: CellId, ancestor_id: CellId) -> bool { + let mut parent = sheet.cell(cell_id).and_then(|cell| cell.parent_id); + while let Some(parent_id) = parent { + if parent_id == ancestor_id { + return true; + } + parent = sheet.cell(parent_id).and_then(|cell| cell.parent_id); + } + false +} + +/// The dragged cell plus every descendant, in sheet order. Empty if unknown. +#[must_use] +pub fn subtree_cell_ids(sheet: &Sheet, root_id: CellId) -> Vec { + sheet + .cells + .iter() + .filter(|cell| cell.id == root_id || is_descendant_of(sheet, cell.id, root_id)) + .map(|cell| cell.id) + .collect() +} + +pub fn sheet_column_ids(board: &FlowBoardProjection, sheet_id: SheetId) -> anyhow::Result> { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let definition = board + .format + .sheet_type(sheet.sheet_type_id) + .context("unknown sheet type")?; + Ok(definition.columns.iter().map(|column| column.id).collect()) +} + +/// Flat insertion index appending AFTER every existing descendant of `parent`. +pub fn child_append_index(board: &FlowBoardProjection, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent_id) + .context("unknown cell")?; + Ok( + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent_id)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1, + ) +} + +/// Flat insertion index immediately after `parent` (before existing subtrees). +pub fn child_prepend_index(board: &FlowBoardProjection, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent_id) + .context("unknown cell")?; + Ok(parent_index + 1) +} + +/// Focus fallback after deleting `cell_id`: previous same-column sibling, then +/// parent, then the last root of the column to the left. +#[must_use] +pub fn deletion_fallback(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = board.sheet(sheet_id)?; + let definition = board.format.sheet_type(sheet.sheet_type_id)?; + let index = sheet.cells.iter().position(|cell| cell.id == cell_id)?; + let cell = &sheet.cells[index]; + + if let Some(previous) = sheet.cells[..index] + .iter() + .rev() + .find(|candidate| candidate.column_id == cell.column_id && candidate.parent_id == cell.parent_id) + { + return Some(previous.id); + } + if let Some(parent) = cell.parent_id { + return Some(parent); + } + + let column = definition + .columns + .iter() + .position(|column| column.id == cell.column_id)?; + let left_column = column + .checked_sub(1) + .and_then(|index| definition.columns.get(index))? + .id; + sheet + .cells + .iter() + .rev() + .find(|candidate| candidate.column_id == left_column && candidate.parent_id.is_none()) + .map(|candidate| candidate.id) +} + +/// Cheap structural check of the two move-sensitive invariants — parent/child +/// column adjacency and contiguous sibling runs per column. A move never +/// touches cell content, so this is exactly the failure set a move can +/// introduce. +#[must_use] +pub fn sheet_topology_ok(sheet: &Sheet, column_ids: &[Uuid]) -> bool { + let level = |column: Uuid| column_ids.iter().position(|candidate| *candidate == column); + let cells: HashMap = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); + for cell in &sheet.cells { + let Some(cell_level) = level(cell.column_id) else { + return false; + }; + if let Some(parent_id) = cell.parent_id { + let Some(parent) = cells.get(&parent_id) else { + return false; + }; + let Some(parent_level) = level(parent.column_id) else { + return false; + }; + if cell_level != parent_level + 1 { + return false; + } + } + } + for &column in column_ids { + let mut completed_parents = HashSet::new(); + let mut current_parent = None; + for cell in sheet.cells.iter().filter(|cell| cell.column_id == column) { + if cell.parent_id != current_parent { + if let Some(parent) = current_parent { + completed_parents.insert(parent); + } + if cell + .parent_id + .is_some_and(|parent| completed_parents.contains(&parent)) + { + return false; + } + current_parent = cell.parent_id; + } + } + } + true +} + +/// Move `cell_id` (and its subtree) within `sheet` according to `intent`. +/// Pure over the sheet, so the same algorithm backs the committing runtime +/// AND the read-only drag previews. +pub fn apply_move_subtree(sheet: &mut Sheet, column_ids: &[Uuid], cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { + let source = sheet + .cells + .iter() + .position(|cell| cell.id == cell_id) + .context("unknown cell")?; + let source_level = column_ids + .iter() + .position(|column| *column == sheet.cells[source].column_id) + .context("source cell references unknown column")?; + + let (target_column, target_index, new_parent) = resolve_drop_intent(sheet, column_ids, cell_id, intent)?; + let level_delta = target_column as isize - source_level as isize; + let subtree_ids = subtree_cell_ids(sheet, cell_id); + if let Some(parent) = new_parent + && subtree_ids.contains(&parent) + { + bail!("cannot move a cell into itself or one of its descendants"); + } + let removed_before_target = sheet + .cells + .iter() + .take(target_index.min(sheet.cells.len())) + .filter(|cell| subtree_ids.contains(&cell.id)) + .count(); + + let mut subtree = Vec::new(); + let mut remaining = Vec::with_capacity(sheet.cells.len()); + for cell in sheet.cells.drain(..) { + if subtree_ids.contains(&cell.id) { + subtree.push(cell); + } else { + remaining.push(cell); + } + } + let insertion_index = target_index + .saturating_sub(removed_before_target) + .min(remaining.len()); + + for cell in &mut subtree { + let old_level = column_ids + .iter() + .position(|column| *column == cell.column_id) + .context("subtree cell references unknown column")?; + let new_level = old_level + .checked_add_signed(level_delta) + .filter(|level| *level < column_ids.len()) + .context("moving this subtree would place a descendant outside the sheet columns")?; + cell.column_id = column_ids[new_level]; + if cell.id == cell_id { + cell.parent_id = new_parent; + } + } + remaining.splice(insertion_index..insertion_index, subtree); + sheet.cells = remaining; + Ok(()) +} + +pub fn resolve_drop_intent( + sheet: &Sheet, + column_ids: &[Uuid], + dragged: CellId, + intent: FlowDropIntent, +) -> anyhow::Result<(usize, usize, Option)> { + match intent { + FlowDropIntent::BeforeSibling(target) | FlowDropIntent::AfterSibling(target) => { + if dragged == target { + bail!("cannot drop a cell relative to itself"); + } + if is_descendant_of(sheet, target, dragged) { + bail!("cannot drop a cell relative to one of its descendants"); + } + let target_index = sheet + .cells + .iter() + .position(|cell| cell.id == target) + .context("unknown target cell")?; + let target_cell = &sheet.cells[target_index]; + let column_index = column_ids + .iter() + .position(|column| *column == target_cell.column_id) + .context("target cell references unknown column")?; + let insertion_index = match intent { + FlowDropIntent::BeforeSibling(_) => target_index, + FlowDropIntent::AfterSibling(_) => target_index + 1, + FlowDropIntent::FirstChildOf(_) | FlowDropIntent::LastChildOf(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((column_index, insertion_index, target_cell.parent_id)) + }, + FlowDropIntent::FirstChildOf(parent) | FlowDropIntent::LastChildOf(parent) => { + if dragged == parent { + bail!("cannot drop a cell onto itself"); + } + if is_descendant_of(sheet, parent, dragged) { + bail!("cannot drop a cell onto one of its descendants"); + } + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent) + .context("unknown parent cell")?; + let parent_cell = &sheet.cells[parent_index]; + let parent_column = column_ids + .iter() + .position(|column| *column == parent_cell.column_id) + .context("parent cell references unknown column")?; + let child_column = parent_column + .checked_add(1) + .filter(|index| *index < column_ids.len()) + .context("parent has no child column")?; + let insertion_index = match intent { + FlowDropIntent::FirstChildOf(_) => parent_index + 1, + FlowDropIntent::LastChildOf(_) => { + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1 + }, + FlowDropIntent::BeforeSibling(_) | FlowDropIntent::AfterSibling(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((child_column, insertion_index, Some(parent))) + }, + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => { + if column_index >= column_ids.len() { + bail!("column index out of range"); + } + Ok((column_index, insertion_index, None)) + }, + } +} + +/// Read-only preview of a subtree move: the sheet with the drag already +/// applied, or `None` if the move is invalid (self/descendant drop, a +/// descendant outside the columns, or a landing the committed move would +/// reject). Safe every drag frame — zero gate traffic. +#[must_use] +pub fn preview_move_cell_subtree(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> Option { + let column_ids = sheet_column_ids(board, sheet_id).ok()?; + let mut sheet = board.sheet(sheet_id)?.clone(); + apply_move_subtree(&mut sheet, &column_ids, cell_id, intent).ok()?; + sheet_topology_ok(&sheet, &column_ids).then_some(sheet) +} + +/// Read-only preview with the dragged subtree lifted out (drag in flight, no +/// valid target hovered). +#[must_use] +pub fn preview_without_subtree(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = board.sheet(sheet_id)?; + let subtree: HashSet = subtree_cell_ids(sheet, cell_id).into_iter().collect(); + if subtree.is_empty() { + return None; + } + let mut sheet = sheet.clone(); + sheet.cells.retain(|cell| !subtree.contains(&cell.id)); + Some(sheet) +} diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs index 1e371bc1..fe454504 100644 --- a/crates/flowstate-flow/src/document.rs +++ b/crates/flowstate-flow/src/document.rs @@ -8,104 +8,16 @@ use uuid::Uuid; use crate::{ FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, }; - -pub type FormatId = Uuid; -pub type SheetTypeId = Uuid; -pub type SheetId = Uuid; -pub type ColumnId = Uuid; -pub type CellId = Uuid; -pub type StrokeId = Uuid; - -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct AnnotationOriginator(pub String); +pub use crate::format::{ + AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, CellId, ColumnDefinition, ColumnId, FlowFormat, FormatId, + SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, +}; const FORMAT_KEY: &str = "immutable-format"; const ANNOTATIONS_MAP: &str = "flow-annotations"; const SHEETS_MAP: &str = "flow-sheets"; const CELLS_MAP: &str = "flow-cells"; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum ArgumentSide { - One, - Two, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ColumnDefinition { - pub id: ColumnId, - pub label: String, - pub side: ArgumentSide, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SheetTypeDefinition { - pub id: SheetTypeId, - pub name: String, - pub columns: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct FlowFormat { - pub id: FormatId, - pub name: String, - pub sheet_types: Vec, -} - -impl FlowFormat { - pub fn policy_debate() -> Self { - let affirmative = sheet_type( - "Affirmative", - &[ - ("1AC", ArgumentSide::One), - ("1NC", ArgumentSide::Two), - ("2AC", ArgumentSide::One), - ("Block", ArgumentSide::Two), - ("1AR", ArgumentSide::One), - ("2NR", ArgumentSide::Two), - ("2AR", ArgumentSide::One), - ], - ); - let negative = sheet_type( - "Negative", - &[ - ("1NC", ArgumentSide::Two), - ("2AC", ArgumentSide::One), - ("Block", ArgumentSide::Two), - ("1AR", ArgumentSide::One), - ("2NR", ArgumentSide::Two), - ("2AR", ArgumentSide::One), - ], - ); - Self { - id: Uuid::new_v4(), - name: "Policy Debate".into(), - sheet_types: vec![affirmative, negative], - } - } - - pub fn sheet_type(&self, id: SheetTypeId) -> Option<&SheetTypeDefinition> { - self - .sheet_types - .iter() - .find(|definition| definition.id == id) - } -} - -fn sheet_type(name: &str, columns: &[(&str, ArgumentSide)]) -> SheetTypeDefinition { - SheetTypeDefinition { - id: Uuid::new_v4(), - name: name.into(), - columns: columns - .iter() - .map(|(label, side)| ColumnDefinition { - id: Uuid::new_v4(), - label: (*label).into(), - side: *side, - }) - .collect(), - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Cell { pub id: CellId, @@ -203,35 +115,6 @@ fn paragraph_text(document: &flowstate_document::DocumentProjection, index: usiz .to_string() } -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BoardPoint { - pub x: f32, - pub y: f32, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct BoardRect { - pub min: BoardPoint, - pub max: BoardPoint, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct StrokeStyle { - pub color_rgba: u32, - pub width: f32, - pub opacity: f32, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct AnnotationStroke { - pub id: StrokeId, - pub sheet_id: SheetId, - pub originator: AnnotationOriginator, - pub points: Vec, - pub style: StrokeStyle, - pub bbox: BoardRect, -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub id: SheetId, diff --git a/crates/flowstate-flow/src/format.rs b/crates/flowstate-flow/src/format.rs new file mode 100644 index 00000000..6feedc1e --- /dev/null +++ b/crates/flowstate-flow/src/format.rs @@ -0,0 +1,129 @@ +//! The immutable flow FORMAT vocabulary (.fl0 v2 spec, Part A) plus the shared +//! id aliases and annotation data types. The format is written once into +//! `flow.meta` as a postcard blob and never mutated afterwards — every peer +//! reads the identical column/sheet-type topology, so the normalization law can +//! treat it as a constant. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub type FormatId = Uuid; +pub type SheetTypeId = Uuid; +pub type SheetId = Uuid; +pub type ColumnId = Uuid; +pub type CellId = Uuid; +pub type StrokeId = Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AnnotationOriginator(pub String); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArgumentSide { + One, + Two, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColumnDefinition { + pub id: ColumnId, + pub label: String, + pub side: ArgumentSide, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SheetTypeDefinition { + pub id: SheetTypeId, + pub name: String, + pub columns: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFormat { + pub id: FormatId, + pub name: String, + pub sheet_types: Vec, +} + +impl FlowFormat { + pub fn policy_debate() -> Self { + let affirmative = sheet_type( + "Affirmative", + &[ + ("1AC", ArgumentSide::One), + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + let negative = sheet_type( + "Negative", + &[ + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + Self { + id: Uuid::new_v4(), + name: "Policy Debate".into(), + sheet_types: vec![affirmative, negative], + } + } + + pub fn sheet_type(&self, id: SheetTypeId) -> Option<&SheetTypeDefinition> { + self + .sheet_types + .iter() + .find(|definition| definition.id == id) + } +} + +fn sheet_type(name: &str, columns: &[(&str, ArgumentSide)]) -> SheetTypeDefinition { + SheetTypeDefinition { + id: Uuid::new_v4(), + name: name.into(), + columns: columns + .iter() + .map(|(label, side)| ColumnDefinition { + id: Uuid::new_v4(), + label: (*label).into(), + side: *side, + }) + .collect(), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardPoint { + pub x: f32, + pub y: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardRect { + pub min: BoardPoint, + pub max: BoardPoint, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeStyle { + pub color_rgba: u32, + pub width: f32, + pub opacity: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnnotationStroke { + pub id: StrokeId, + pub sheet_id: SheetId, + pub originator: AnnotationOriginator, + pub points: Vec, + pub style: StrokeStyle, + pub bbox: BoardRect, +} diff --git a/crates/flowstate-flow/src/intents.rs b/crates/flowstate-flow/src/intents.rs new file mode 100644 index 00000000..b5572fb8 --- /dev/null +++ b/crates/flowstate-flow/src/intents.rs @@ -0,0 +1,153 @@ +//! The plain-data flow intent vocabulary (.fl0 v2 spec, Part B). +//! +//! Every local mutation of a flow document is one of these intents, applied +//! through the flow's gated write path (`flowstate-collab::flow`). The types +//! are pure data — no Loro handles, no projection references — so the editor +//! can construct them without holding the gate, and the fuzz harnesses can +//! generate them. + +use gpui_flowtext::local_intents::LocalIntent; +use serde::{Deserialize, Serialize}; + +use crate::format::{AnnotationOriginator, AnnotationStroke, CellId, SheetId, SheetTypeId, StrokeId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RelativePosition { + Before, + After, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FlowDropIntent { + BeforeSibling(CellId), + AfterSibling(CellId), + FirstChildOf(CellId), + LastChildOf(CellId), + RootInColumn { column_index: usize, insertion_index: usize }, +} + +/// Where a NEW cell lands, replacing the former `add_plain_cell` / +/// `add_orphan_at_column_top` / `add_sibling` / `add_response` / +/// `add_first_response` quintet with one resolved-at-commit placement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CellPlacement { + /// Root cell appended at the end of the sheet order. + ColumnEnd { column_index: usize }, + /// Root cell at the very top of the sheet order (`add_orphan_at_column_top`). + ColumnTop { column_index: usize }, + /// Immediately before/after an existing cell, sharing its column and parent. + Sibling { of: CellId, position: RelativePosition }, + /// First child of `parent` (before its existing child subtrees). + FirstResponseTo { parent: CellId }, + /// Last child of `parent` (after every existing descendant). + ResponseTo { parent: CellId }, +} + +/// Initial rich-text content for a new cell. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum CellSeed { + /// The canonical empty tag-paragraph seed (`seed_cell_flow`). + #[default] + Empty, + /// Full paragraph content (paste of a card, import). + Paragraphs(Vec), +} + +/// One local flow intent = one gate hold = one Loro commit = one undo member. +#[derive(Clone, Debug)] +pub enum FlowIntent { + CreateSheet { + sheet_id: SheetId, + name: String, + sheet_type_id: SheetTypeId, + }, + RenameSheet { + sheet_id: SheetId, + name: String, + }, + DeleteSheet { + sheet_id: SheetId, + }, + MoveSheet { + sheet_id: SheetId, + target_index: usize, + }, + AddCell { + sheet_id: SheetId, + cell_id: CellId, + placement: CellPlacement, + seed: CellSeed, + }, + DeleteCell { + sheet_id: SheetId, + cell_id: CellId, + }, + MoveCellSubtree { + sheet_id: SheetId, + cell_id: CellId, + drop: FlowDropIntent, + }, + /// Toggle-free strike state: `struck` is the desired end state, expressed as + /// a whole-text `MARK_STRIKETHROUGH` mark so concurrent typing merges under + /// it char-level. + SetCellStruck { + sheet_id: SheetId, + cell_id: CellId, + struck: bool, + }, + /// Restyle the first paragraph to the editable tag style when the cell's + /// content has no summary-projection row yet (former + /// `ensure_cell_editable_projection`). + EnsureCellEditable { + sheet_id: SheetId, + cell_id: CellId, + }, + ReplaceCellContent { + sheet_id: SheetId, + cell_id: CellId, + paragraphs: Vec, + }, + AddAnnotation { + sheet_id: SheetId, + stroke: AnnotationStroke, + }, + DeleteAnnotation { + sheet_id: SheetId, + stroke_id: StrokeId, + originator: AnnotationOriginator, + }, + ClearAnnotations { + /// `None` clears the originator's strokes on every sheet. + sheet_id: Option, + originator: AnnotationOriginator, + }, + /// A rich-text intent scoped to one cell's flow, translated from the cell's + /// `RichTextEditor` by its `FlowCellAuthority`. + CellText { + cell_id: CellId, + intent: LocalIntent, + }, +} + +impl FlowIntent { + /// Commit-message class (field forensics, mirrors `LocalIntent::class`). + #[must_use] + pub fn class(&self) -> &'static str { + match self { + Self::CreateSheet { .. } => "flow-create-sheet", + Self::RenameSheet { .. } => "flow-rename-sheet", + Self::DeleteSheet { .. } => "flow-delete-sheet", + Self::MoveSheet { .. } => "flow-move-sheet", + Self::AddCell { .. } => "flow-add-cell", + Self::DeleteCell { .. } => "flow-delete-cell", + Self::MoveCellSubtree { .. } => "flow-move-cell-subtree", + Self::SetCellStruck { .. } => "flow-set-cell-struck", + Self::EnsureCellEditable { .. } => "flow-ensure-cell-editable", + Self::ReplaceCellContent { .. } => "flow-replace-cell-content", + Self::AddAnnotation { .. } => "flow-add-annotation", + Self::DeleteAnnotation { .. } => "flow-delete-annotation", + Self::ClearAnnotations { .. } => "flow-clear-annotations", + Self::CellText { .. } => "flow-cell-text", + } + } +} diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index 18c0b440..dcbe0b6b 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -3,6 +3,14 @@ mod document; mod operations; mod persistence; +// ---- .fl0 v2 (CRDT-first) — spec Parts A/B --------------------------------- +pub mod board_ops; +pub mod format; +pub mod intents; +pub mod loro_projection; +pub mod loro_schema; +pub mod projection; + pub use collaboration::{ FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, }; @@ -11,6 +19,11 @@ pub use document::{ AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, Cell, CellId, ColumnDefinition, ColumnId, FlowDocument, FlowFormat, FlowProjection, FormatId, Sheet, SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, }; +pub use intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; pub use loro::VersionVector; -pub use operations::{FlowDropIntent, RelativePosition}; -pub use persistence::{decode as decode_flow_document, encode as encode_flow_document, load_flow_document, save_flow_document}; +pub use loro_projection::FlowDefect; +pub use persistence::{ + FL0_VERSION, decode as decode_flow_document, decode_fl0_snapshot, encode as encode_flow_document, encode_fl0_snapshot, load_flow_document, + read_fl0, save_flow_document, write_fl0, +}; +pub use projection::{CellSummary, FlowBoardProjection}; diff --git a/crates/flowstate-flow/src/loro_projection.rs b/crates/flowstate-flow/src/loro_projection.rs new file mode 100644 index 00000000..51333b01 --- /dev/null +++ b/crates/flowstate-flow/src/loro_projection.rs @@ -0,0 +1,504 @@ +//! The TOTAL board materializer + normalization law (.fl0 v2 spec, Part A). +//! +//! Merged LWW states can violate flow invariants (concurrent moves, deletes +//! racing adds, parent rewrites crossing sheet deletes). The materializer +//! never fails on such states — it applies deterministic normalization (a pure +//! function of canonical state, so every peer converges on the identical +//! board) and reports [`FlowDefect`]s in the .db8 quarantine philosophy. The +//! import path never writes repairs; a capped `"repair"`-origin local +//! canonicalization pass (the runtime) may later rewrite canonical state to +//! match, converging with what every peer already renders. +//! +//! Normalization order: +//! 1. liveness (in map ∧ in order; repair by deterministic append / skip), +//! 2. unknown column → clamp to the first column, +//! 3. dangling parent → orphan, +//! 4. parent-cycle break (null the greatest-uuid member), +//! 5. column-adjacency violation → orphan, +//! 6. sibling-run contiguity → canonical DFS re-linearization. + +use std::sync::Arc; + +use flowstate_document::{ + PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, RegionRows, SEMANTIC_CITE, materialize_single_flow, +}; +use gpui_flowtext::{DocumentProjection, DocumentTheme, InputBlock, document_from_input_blocks}; +use loro::{LoroDoc, LoroMap}; +use rustc_hash::{FxHashMap, FxHashSet}; +use uuid::Uuid; + +use crate::format::{CellId, SheetId}; +use crate::loro_schema::{ + cell_flow_label, cell_flow_map, cell_order_ids, cells_by_id, child_map, map_string, map_uuid, read_annotations, read_format, + sheet_order_ids, sheets_by_id, +}; +use crate::projection::{Cell, CellSummary, FlowBoardProjection, Sheet}; + +/// One normalized-away violation of the flow invariants. Every defect is a +/// deterministic function of canonical state; the repair pass keys attempt +/// caps off [`FlowDefect::stable_key`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FlowDefect { + /// Sheet-order entry without a live sheet record → skipped. + SheetOrderMissingRecord { key: String }, + /// Sheet record missing from the board order → appended (uuid-sorted). + SheetRecordMissingOrder { sheet: SheetId }, + /// Sheet record whose sheet type is not in the immutable format → its cells + /// are not materialized (nothing can place them in columns). + UnknownSheetType { sheet: SheetId }, + /// Cell-order entry without a live cell record in that sheet → skipped. + CellOrderMissingRecord { sheet: SheetId, key: String }, + /// Cell record missing from its sheet's order → appended (uuid-sorted). + CellRecordMissingOrder { cell: CellId }, + /// Cell record referencing a dead/unknown sheet → not materialized (the + /// sheet delete wins). + CellRecordUnknownSheet { cell: CellId }, + /// Cell record with a missing/unknown column id → clamped to column 0. + UnknownColumn { cell: CellId }, + /// Parent id not live in the same sheet → orphaned. + DanglingParent { cell: CellId }, + /// Parent cycle → the greatest-uuid member orphaned. + ParentCycle { cell: CellId }, + /// Child column is not exactly one right of its parent's → orphaned. + ColumnAdjacency { cell: CellId }, + /// Raw cell order was not the canonical DFS linearization → re-linearized. + SiblingRunSplit { sheet: SheetId }, + /// Cell record without a seeded flow container → empty summary. + CellMissingFlow { cell: CellId }, + /// A defect reported by the shared rich-text materializer for one cell. + CellFlow { cell: CellId, defect: flowstate_document::ProjectionDefect }, +} + +impl FlowDefect { + #[must_use] + pub fn stable_key(&self) -> String { + match self { + Self::SheetOrderMissingRecord { key } => format!("sheet-order-missing-record:{key}"), + Self::SheetRecordMissingOrder { sheet } => format!("sheet-record-missing-order:{sheet}"), + Self::UnknownSheetType { sheet } => format!("unknown-sheet-type:{sheet}"), + Self::CellOrderMissingRecord { sheet, key } => format!("cell-order-missing-record:{sheet}:{key}"), + Self::CellRecordMissingOrder { cell } => format!("cell-record-missing-order:{cell}"), + Self::CellRecordUnknownSheet { cell } => format!("cell-record-unknown-sheet:{cell}"), + Self::UnknownColumn { cell } => format!("unknown-column:{cell}"), + Self::DanglingParent { cell } => format!("dangling-parent:{cell}"), + Self::ParentCycle { cell } => format!("parent-cycle:{cell}"), + Self::ColumnAdjacency { cell } => format!("column-adjacency:{cell}"), + Self::SiblingRunSplit { sheet } => format!("sibling-run-split:{sheet}"), + Self::CellMissingFlow { cell } => format!("cell-missing-flow:{cell}"), + Self::CellFlow { cell, defect } => format!("cell-flow:{cell}:{}", defect.stable_key()), + } + } +} + +/// Materialize the whole board. Deterministic: byte-equal boards on every peer +/// sharing the same canonical state, regardless of local op history. +pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, Vec)> { + let format = read_format(doc)?; + let mut defects = Vec::new(); + + // ---- Sheet linearization (normalization step 1, sheet scope) ------------- + let sheet_registry = sheets_by_id(doc); + let mut live_sheet_keys: Vec = { + let mut keys: Vec = sheet_registry.keys().map(|key| key.to_string()).collect(); + keys.sort(); + keys + }; + let ordered = sheet_order_ids(doc); + let live: FxHashSet<&String> = live_sheet_keys.iter().collect(); + let mut sheet_keys: Vec = Vec::with_capacity(live_sheet_keys.len()); + let mut seen: FxHashSet = FxHashSet::default(); + for key in &ordered { + if !live.contains(key) { + defects.push(FlowDefect::SheetOrderMissingRecord { key: key.clone() }); + continue; + } + if seen.insert(key.clone()) { + sheet_keys.push(key.clone()); + } + } + live_sheet_keys.retain(|key| !seen.contains(key)); + for key in live_sheet_keys { + // Record without an order entry: deterministic uuid-sorted append. + if let Ok(sheet) = Uuid::parse_str(&key) { + defects.push(FlowDefect::SheetRecordMissingOrder { sheet }); + } + sheet_keys.push(key); + } + + // ---- Cells grouped by sheet ---------------------------------------------- + let cell_registry = cells_by_id(doc); + let mut cell_keys: Vec = cell_registry.keys().map(|key| key.to_string()).collect(); + cell_keys.sort(); + struct RawCell { + id: CellId, + sheet_id: SheetId, + column_id: Option, + parent_id: Option, + map: LoroMap, + } + let mut cells_by_sheet: FxHashMap> = FxHashMap::default(); + let live_sheet_ids: FxHashSet = sheet_keys + .iter() + .filter_map(|key| Uuid::parse_str(key).ok()) + .collect(); + for key in cell_keys { + let Some(map) = child_map(&cell_registry, &key) else { + continue; + }; + let Some(id) = map_uuid(&map, "id").filter(|id| id.to_string() == key) else { + continue; + }; + let Some(sheet_id) = map_uuid(&map, "sheet_id") else { + defects.push(FlowDefect::CellRecordUnknownSheet { cell: id }); + continue; + }; + if !live_sheet_ids.contains(&sheet_id) { + defects.push(FlowDefect::CellRecordUnknownSheet { cell: id }); + continue; + } + cells_by_sheet.entry(sheet_id).or_default().push(RawCell { + id, + sheet_id, + column_id: map_uuid(&map, "column_id"), + parent_id: map_uuid(&map, "parent_id"), + map, + }); + } + + // ---- Per-sheet normalization ---------------------------------------------- + let mut sheets = Vec::with_capacity(sheet_keys.len()); + for key in &sheet_keys { + let Some(sheet_map) = child_map(&sheet_registry, key) else { + continue; + }; + let Ok(sheet_id) = Uuid::parse_str(key) else { + continue; + }; + let name = map_string(&sheet_map, "name").unwrap_or_default(); + let Some(sheet_type_id) = map_uuid(&sheet_map, "sheet_type_id") else { + defects.push(FlowDefect::UnknownSheetType { sheet: sheet_id }); + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id: Uuid::nil(), + cells: Vec::new(), + annotations: Vec::new(), + }); + continue; + }; + let Some(definition) = format.sheet_type(sheet_type_id) else { + defects.push(FlowDefect::UnknownSheetType { sheet: sheet_id }); + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id, + cells: Vec::new(), + annotations: Vec::new(), + }); + continue; + }; + let column_ids: Vec = definition.columns.iter().map(|column| column.id).collect(); + + let mut raw_cells = cells_by_sheet.remove(&sheet_id).unwrap_or_default(); + debug_assert!(raw_cells.iter().all(|cell| cell.sheet_id == sheet_id)); + + // Step 1 (cell liveness): order ∧ map, deterministic appends for + // record-without-order, skips (+defect) for order-without-record. + let by_id: FxHashMap = raw_cells + .iter() + .enumerate() + .map(|(ix, cell)| (cell.id, ix)) + .collect(); + let mut linear: Vec = Vec::with_capacity(raw_cells.len()); + let mut seen: FxHashSet = FxHashSet::default(); + for entry in cell_order_ids(&sheet_map) { + let Some(id) = Uuid::parse_str(&entry).ok().filter(|id| by_id.contains_key(id)) else { + defects.push(FlowDefect::CellOrderMissingRecord { + sheet: sheet_id, + key: entry, + }); + continue; + }; + if seen.insert(id) { + linear.push(id); + } + } + // raw_cells is uuid-key-sorted already, so the append order is + // deterministic. + for cell in &raw_cells { + if !seen.contains(&cell.id) { + defects.push(FlowDefect::CellRecordMissingOrder { cell: cell.id }); + linear.push(cell.id); + seen.insert(cell.id); + } + } + + // Step 2 (unknown column → clamp) — requires at least one column; a + // format with an empty column list cannot place any cell. + let level_of = |column: Option| column.and_then(|column| column_ids.iter().position(|candidate| *candidate == column)); + for cell in &mut raw_cells { + if level_of(cell.column_id).is_none() { + defects.push(FlowDefect::UnknownColumn { cell: cell.id }); + cell.column_id = column_ids.first().copied(); + } + } + + // Step 3 (dangling parent → orphan). + for ix in 0..raw_cells.len() { + if let Some(parent) = raw_cells[ix].parent_id + && !by_id.contains_key(&parent) + { + defects.push(FlowDefect::DanglingParent { cell: raw_cells[ix].id }); + raw_cells[ix].parent_id = None; + } + } + + // Step 4 (cycle break: null the greatest-uuid member of each cycle). + loop { + let parent_of: FxHashMap = raw_cells + .iter() + .filter_map(|cell| cell.parent_id.map(|parent| (cell.id, parent))) + .collect(); + let mut broken: Option = None; + let mut resolved: FxHashSet = FxHashSet::default(); + for cell in &raw_cells { + if resolved.contains(&cell.id) { + continue; + } + let mut chain: Vec = Vec::new(); + let mut on_chain: FxHashSet = FxHashSet::default(); + let mut cursor = cell.id; + loop { + if resolved.contains(&cursor) { + break; + } + if !on_chain.insert(cursor) { + // Cycle: members are the chain suffix from the first occurrence. + let start = chain.iter().position(|id| *id == cursor).unwrap_or(0); + let member = chain[start..].iter().copied().max().unwrap_or(cursor); + broken = Some(member); + break; + } + chain.push(cursor); + match parent_of.get(&cursor) { + Some(parent) => cursor = *parent, + None => break, + } + } + if broken.is_some() { + break; + } + resolved.extend(chain); + } + let Some(member) = broken else { + break; + }; + defects.push(FlowDefect::ParentCycle { cell: member }); + if let Some(cell) = raw_cells.iter_mut().find(|cell| cell.id == member) { + cell.parent_id = None; + } + } + + // Step 5 (column adjacency → orphan). Depends only on the cell's own + // column vs its parent's, so a single pass suffices. + let levels: FxHashMap = raw_cells + .iter() + .filter_map(|cell| level_of(cell.column_id).map(|level| (cell.id, level))) + .collect(); + for ix in 0..raw_cells.len() { + let Some(parent) = raw_cells[ix].parent_id else { + continue; + }; + let child_level = levels.get(&raw_cells[ix].id).copied(); + let parent_level = levels.get(&parent).copied(); + let adjacent = matches!((child_level, parent_level), (Some(child), Some(parent)) if child == parent + 1); + if !adjacent { + defects.push(FlowDefect::ColumnAdjacency { cell: raw_cells[ix].id }); + raw_cells[ix].parent_id = None; + } + } + + // Step 6 (canonical DFS re-linearization): roots in linear order, each + // followed by its whole subtree, children in linear order. A well-formed + // order maps to itself; anything else regroups deterministically. + let normalized: FxHashMap = raw_cells.iter().map(|cell| (cell.id, cell)).collect(); + let mut children: FxHashMap> = FxHashMap::default(); + let mut roots: Vec = Vec::new(); + for id in &linear { + match normalized.get(id).and_then(|cell| cell.parent_id) { + Some(parent) => children.entry(parent).or_default().push(*id), + None => roots.push(*id), + } + } + let mut canonical: Vec = Vec::with_capacity(linear.len()); + let mut stack: Vec = roots.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + canonical.push(id); + if let Some(kids) = children.get(&id) { + stack.extend(kids.iter().rev()); + } + } + debug_assert_eq!(canonical.len(), linear.len(), "DFS must visit every live cell exactly once"); + if canonical != linear { + defects.push(FlowDefect::SiblingRunSplit { sheet: sheet_id }); + } + + // ---- Summaries ----------------------------------------------------------- + let mut cells = Vec::with_capacity(canonical.len()); + for id in canonical { + let raw = normalized[&id]; + let summary = match cell_flow_map(&raw.map) { + Some(flow) => { + let label = cell_flow_label(id); + match materialize_single_flow(doc, &flow, &label) { + Ok(rows) => { + for defect in rows.defects.iter().cloned() { + defects.push(FlowDefect::CellFlow { cell: id, defect }); + } + summary_from_rows(&rows.blocks) + }, + Err(_) => { + defects.push(FlowDefect::CellMissingFlow { cell: id }); + CellSummary::default() + }, + } + }, + None => { + defects.push(FlowDefect::CellMissingFlow { cell: id }); + CellSummary::default() + }, + }; + cells.push(Cell { + id, + column_id: raw.column_id.unwrap_or_else(Uuid::nil), + parent_id: raw.parent_id, + summary, + }); + } + + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id, + cells, + annotations: Vec::new(), + }); + } + + // ---- Annotations (unchanged convergent blobs, routed by sheet) ----------- + for stroke in read_annotations(doc) { + if let Some(sheet) = sheets.iter_mut().find(|sheet| sheet.id == stroke.sheet_id) { + sheet.annotations.push(stroke); + } + } + + Ok((FlowBoardProjection { format, sheets }, defects)) +} + +/// Materialize ONE cell's full rich-text projection (editor attach). The board +/// carries only summaries; this is the on-demand deep materialization. +pub fn materialize_cell_projection(doc: &LoroDoc, cell_id: CellId, theme: DocumentTheme) -> anyhow::Result { + let rows = materialize_cell_rows(doc, cell_id)?; + Ok(document_from_rows(doc, rows, theme)) +} + +/// The raw materialized rows for one cell (shared by the projection above and +/// the runtime's summary refresh). +pub fn materialize_cell_rows(doc: &LoroDoc, cell_id: CellId) -> anyhow::Result { + let cell = crate::loro_schema::cell_map(doc, cell_id).ok_or_else(|| anyhow::anyhow!("unknown cell {cell_id}"))?; + let flow = cell_flow_map(&cell).ok_or_else(|| anyhow::anyhow!("cell {cell_id} has no flow"))?; + Ok(materialize_single_flow(doc, &flow, &cell_flow_label(cell_id))?) +} + +/// Assemble a full per-cell `DocumentProjection` from materialized rows: +/// durable paragraph/block ids installed, frontier stamped. +#[must_use] +pub fn document_from_rows(doc: &LoroDoc, rows: RegionRows, theme: DocumentTheme) -> DocumentProjection { + let RegionRows { + blocks, + paragraph_ids, + block_ids, + defects: _, + } = rows; + let mut document = document_from_input_blocks(theme, blocks); + if paragraph_ids.len() == document.paragraphs.len() { + document.ids.paragraph_ids = Arc::new(paragraph_ids); + } + if block_ids.len() == document.blocks.len() { + document.ids.block_ids = Arc::new(block_ids); + } + document.frontier = doc.state_frontiers().encode(); + document +} + +/// The board-summary law (former `Cell::summary_text` / +/// `uses_summary_projection` / strike detection), over materialized rows. +#[must_use] +pub fn summary_from_rows(blocks: &[InputBlock]) -> CellSummary { + let paragraphs: Vec<&gpui_flowtext::InputParagraph> = blocks + .iter() + .filter_map(|block| match block { + InputBlock::Paragraph(paragraph) => Some(paragraph), + _ => None, + }) + .collect(); + + let mut full_text = String::new(); + let mut summary_rows: Vec = Vec::new(); + let mut uses_summary_projection = false; + let mut any_visible_run = false; + let mut all_struck = true; + + for (ix, paragraph) in paragraphs.iter().enumerate() { + let text: String = paragraph.runs.iter().map(|run| run.text.as_str()).collect(); + if ix > 0 { + full_text.push('\n'); + } + full_text.push_str(&text); + + for run in ¶graph.runs { + if !run.text.is_empty() { + any_visible_run = true; + if !run.styles.strikethrough { + all_struck = false; + } + } + } + + if matches!(paragraph.style, PARAGRAPH_TAG | PARAGRAPH_UNDERTAG | PARAGRAPH_ANALYTIC) { + uses_summary_projection = true; + summary_rows.push(text); + continue; + } + // An empty cite run still flips the summary-projection flag (the former + // `uses_summary_projection` law) — only non-empty cite text becomes a row. + if paragraph + .runs + .iter() + .any(|run| run.styles.semantic == SEMANTIC_CITE) + { + uses_summary_projection = true; + } + let cite_text: String = paragraph + .runs + .iter() + .filter(|run| run.styles.semantic == SEMANTIC_CITE) + .map(|run| run.text.as_str()) + .collect(); + if !cite_text.is_empty() { + summary_rows.push(cite_text); + } + } + + let summary_text: Arc = if summary_rows.is_empty() { + Arc::from(full_text.as_str()) + } else { + Arc::from(summary_rows.join("\n").as_str()) + }; + CellSummary { + summary_text, + uses_summary_projection, + struck: any_visible_run && all_struck, + is_empty: full_text.trim().is_empty(), + } +} diff --git a/crates/flowstate-flow/src/loro_schema.rs b/crates/flowstate-flow/src/loro_schema.rs new file mode 100644 index 00000000..c9ca207d --- /dev/null +++ b/crates/flowstate-flow/src/loro_schema.rs @@ -0,0 +1,514 @@ +//! The .fl0 v2 Loro container schema (spec Part A). +//! +//! ```text +//! LoroDoc (.fl0 v2) +//! ├─ "flow.meta" (Map): format (postcard, immutable), schema_version = 2, +//! │ document_id (uuid string), created_at/modified_at +//! ├─ "flow.sheet_order" (MovableList) — board order of sheet uuids +//! ├─ "flow.sheets_by_id" (Map) → {sheet_uuid} (Map): +//! │ id, name (LWW), sheet_type_id, "cell_order" (MovableList) +//! ├─ "flow.cells_by_id" (Map) → {cell_uuid} (Map): +//! │ id, sheet_id, column_id (LWW), parent_id (LWW, absent = root), +//! │ "flow" (Map) — the EXACT .db8 flow shape (`ensure_flow`): sentinel +//! │ text + MARK_* styles + a per-cell "paragraphs_by_id" registry +//! └─ "flow.annotations" (Map): stroke uuid → postcard blob +//! (write-once/delete-only — already proven convergent) +//! ``` +//! +//! Ordering is MovableList (`mov` writes only the order list, never a cell map +//! or text container, so a reorder can never clobber a concurrent text edit); +//! the parent tree is per-cell LWW `parent_id`/`column_id`. Everything here +//! writes WITHOUT committing — the caller (the gated flow runtime, or a test) +//! owns the commit and its origin. + +use flowstate_document::loro_schema::{ + FLOW_TEXT_KEY, MARK_PARAGRAPH_STYLE, PARAGRAPHS_BY_ID, ROOT_FIRST_PARAGRAPH_ID, configure_text_styles, ensure_flow, +}; +use gpui_flowtext::{InputParagraph, ParagraphStyle, RunSemanticStyle}; +use loro::{LoroDoc, LoroMap, LoroMovableList, LoroResult, LoroText, LoroValue, ValueOrContainer, cursor::Side}; +use uuid::Uuid; + +use crate::format::{AnnotationStroke, CellId, FlowFormat, SheetId, StrokeId}; + +pub const FLOW_META: &str = "flow.meta"; +pub const FLOW_SHEET_ORDER: &str = "flow.sheet_order"; +pub const FLOW_SHEETS_BY_ID: &str = "flow.sheets_by_id"; +pub const FLOW_CELLS_BY_ID: &str = "flow.cells_by_id"; +pub const FLOW_ANNOTATIONS: &str = "flow.annotations"; + +pub const META_FORMAT: &str = "format"; +pub const META_SCHEMA_VERSION: &str = "schema_version"; +pub const META_DOCUMENT_ID: &str = "document_id"; + +pub const SHEET_CELL_ORDER: &str = "cell_order"; +pub const CELL_FLOW_KEY: &str = "flow"; +pub const CELL_FLOW_KIND: &str = "flow-cell"; + +/// `.fl0` v2 canonical schema version stamped into `flow.meta`. +pub const FLOW_SCHEMA_VERSION: i64 = 2; + +/// The editable tag paragraph style every fresh cell seeds with +/// (`flowstate_document::PARAGRAPH_TAG`, kept literal here to avoid a gpui dep +/// for one constant — asserted equal in tests). +pub const CELL_SEED_PARAGRAPH_STYLE: ParagraphStyle = ParagraphStyle::Custom(3); + +/// Initialize a fresh v2 flow document: meta (immutable format blob, schema +/// version, document id, timestamps) + the root containers + text-style +/// configuration. Does not commit. +pub fn init_flow_document(doc: &LoroDoc, format: &FlowFormat) -> anyhow::Result { + configure_text_styles(doc); + let meta = doc.get_map(FLOW_META); + meta.insert(META_FORMAT, postcard::to_allocvec(format)?)?; + meta.insert(META_SCHEMA_VERSION, FLOW_SCHEMA_VERSION)?; + let document_id = Uuid::new_v4(); + meta.insert(META_DOCUMENT_ID, document_id.to_string())?; + let now = unix_time_secs(); + meta.insert("created_at", now)?; + meta.insert("modified_at", now)?; + meta.insert("created_by_app_version", env!("CARGO_PKG_VERSION"))?; + Ok(document_id) +} + +/// A snapshot import always re-runs this: style configs are per-`LoroDoc` +/// runtime state, not persisted. +pub fn configure_flow_text_styles(doc: &LoroDoc) { + configure_text_styles(doc); +} + +pub fn read_format(doc: &LoroDoc) -> anyhow::Result { + let meta = doc.get_map(FLOW_META); + let Some(ValueOrContainer::Value(LoroValue::Binary(bytes))) = meta.get(META_FORMAT) else { + anyhow::bail!("flow document has no immutable format definition"); + }; + Ok(postcard::from_bytes(&bytes)?) +} + +pub fn read_schema_version(doc: &LoroDoc) -> Option { + match doc.get_map(FLOW_META).get(META_SCHEMA_VERSION)? { + ValueOrContainer::Value(LoroValue::I64(version)) => Some(version), + _ => None, + } +} + +pub fn read_document_id(doc: &LoroDoc) -> Option { + match doc.get_map(FLOW_META).get(META_DOCUMENT_ID)? { + ValueOrContainer::Value(LoroValue::String(value)) => Uuid::parse_str(&value).ok(), + _ => None, + } +} + +pub fn touch_modified_at(doc: &LoroDoc) -> LoroResult<()> { + doc + .get_map(FLOW_META) + .insert("modified_at", unix_time_secs())?; + Ok(()) +} + +pub fn sheet_order(doc: &LoroDoc) -> LoroMovableList { + doc.get_movable_list(FLOW_SHEET_ORDER) +} + +pub fn sheets_by_id(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_SHEETS_BY_ID) +} + +pub fn cells_by_id(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_CELLS_BY_ID) +} + +pub fn annotations_map(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_ANNOTATIONS) +} + +pub fn sheet_map(doc: &LoroDoc, sheet_id: SheetId) -> Option { + child_map(&sheets_by_id(doc), &sheet_id.to_string()) +} + +pub fn cell_map(doc: &LoroDoc, cell_id: CellId) -> Option { + child_map(&cells_by_id(doc), &cell_id.to_string()) +} + +pub fn cell_flow_map(cell: &LoroMap) -> Option { + child_map(cell, CELL_FLOW_KEY) +} + +pub fn cell_order_list(sheet: &LoroMap) -> LoroResult { + sheet.ensure_mergeable_movable_list(SHEET_CELL_ORDER) +} + +/// Defect-label flow id for a cell's rich text (`materialize_single_flow`). +#[must_use] +pub fn cell_flow_label(cell_id: CellId) -> String { + format!("cell.{cell_id}.flow") +} + +// ---- Sheet records --------------------------------------------------------- + +/// Create (or converge on) a sheet record and insert it into the board order +/// at `order_index` (clamped). Idempotent per field; two peers creating the +/// same sheet id converge by map LWW. +pub fn write_sheet(doc: &LoroDoc, sheet_id: SheetId, name: &str, sheet_type_id: Uuid, order_index: usize) -> LoroResult { + let sheet = sheets_by_id(doc).ensure_mergeable_map(&sheet_id.to_string())?; + sheet.insert("id", sheet_id.to_string())?; + sheet.insert("name", name)?; + sheet.insert("sheet_type_id", sheet_type_id.to_string())?; + cell_order_list(&sheet)?; + let order = sheet_order(doc); + if order_position(&order, &sheet_id.to_string()).is_none() { + order.insert(order_index.min(order.len()), sheet_id.to_string())?; + } + Ok(sheet) +} + +pub fn rename_sheet(doc: &LoroDoc, sheet_id: SheetId, name: &str) -> anyhow::Result<()> { + let sheet = sheet_map(doc, sheet_id).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + sheet.insert("name", name)?; + Ok(()) +} + +/// Remove a sheet: its order entry, its record, its cells (records + order are +/// both dropped with the sheet), and its annotations. +pub fn remove_sheet(doc: &LoroDoc, sheet_id: SheetId) -> anyhow::Result<()> { + let order = sheet_order(doc); + if let Some(position) = order_position(&order, &sheet_id.to_string()) { + order.delete(position, 1)?; + } + let cells = cells_by_id(doc); + let sheet_key = sheet_id.to_string(); + let mut doomed = Vec::new(); + cells.for_each(|key, value| { + if let ValueOrContainer::Container(container) = value + && let Ok(cell) = container.into_map() + && map_string(&cell, "sheet_id").as_deref() == Some(sheet_key.as_str()) + { + doomed.push(key.to_string()); + } + }); + for key in doomed { + cells.delete(&key)?; + } + sheets_by_id(doc).delete(&sheet_key)?; + // Annotations of the sheet: delete-only map, same routing rule as the + // materializer (skip-on-unknown-sheet would also hide them, but deleting + // keeps the map from accumulating garbage). + let annotations = annotations_map(doc); + let mut doomed_strokes = Vec::new(); + annotations.for_each(|key, value| { + if let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value + && let Ok(stroke) = postcard::from_bytes::(&bytes) + && stroke.sheet_id == sheet_id + { + doomed_strokes.push(key.to_string()); + } + }); + for key in doomed_strokes { + annotations.delete(&key)?; + } + Ok(()) +} + +pub fn move_sheet(doc: &LoroDoc, sheet_id: SheetId, target_index: usize) -> anyhow::Result<()> { + let order = sheet_order(doc); + let from = order_position(&order, &sheet_id.to_string()).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + let to = target_index.min(order.len().saturating_sub(1)); + if from != to { + order.mov(from, to)?; + } + Ok(()) +} + +// ---- Cell records ---------------------------------------------------------- + +/// Create a cell record (map fields + seeded flow) and insert its id into the +/// sheet's `cell_order` at `order_index` (clamped). The rich text is seeded +/// separately by the caller (`seed_cell_flow` / `cell_flow_from_paragraphs`). +pub fn write_cell( + doc: &LoroDoc, + sheet_id: SheetId, + cell_id: CellId, + column_id: Uuid, + parent_id: Option, + order_index: usize, +) -> anyhow::Result { + let sheet = sheet_map(doc, sheet_id).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + let cell = cells_by_id(doc).ensure_mergeable_map(&cell_id.to_string())?; + cell.insert("id", cell_id.to_string())?; + cell.insert("sheet_id", sheet_id.to_string())?; + cell.insert("column_id", column_id.to_string())?; + match parent_id { + Some(parent) => cell.insert("parent_id", parent.to_string())?, + None => { + if cell.get("parent_id").is_some() { + cell.delete("parent_id")?; + } + }, + } + let order = cell_order_list(&sheet)?; + if order_position(&order, &cell_id.to_string()).is_none() { + order.insert(order_index.min(order.len()), cell_id.to_string())?; + } + Ok(cell) +} + +pub fn set_cell_column(cell: &LoroMap, column_id: Uuid) -> LoroResult<()> { + cell.insert("column_id", column_id.to_string())?; + Ok(()) +} + +pub fn set_cell_parent(cell: &LoroMap, parent_id: Option) -> LoroResult<()> { + match parent_id { + Some(parent) => cell.insert("parent_id", parent.to_string())?, + None => { + if cell.get("parent_id").is_some() { + cell.delete("parent_id")?; + } + }, + } + Ok(()) +} + +/// Delete a cell record and its order entry (liveness = in map ∧ in order). +pub fn remove_cell(doc: &LoroDoc, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { + if let Some(sheet) = sheet_map(doc, sheet_id) { + let order = cell_order_list(&sheet)?; + if let Some(position) = order_position(&order, &cell_id.to_string()) { + order.delete(position, 1)?; + } + } + cells_by_id(doc).delete(&cell_id.to_string())?; + Ok(()) +} + +/// `MovableListHandler::mov` over the sheet's cell order: `from`/`to` are +/// current-list indices (Loro's move semantics — the element lands at `to` +/// as observed AFTER the removal of `from`). +pub fn move_cell_order(sheet: &LoroMap, from: usize, to: usize) -> anyhow::Result<()> { + let order = cell_order_list(sheet)?; + if from != to { + order.mov(from, to)?; + } + Ok(()) +} + +pub fn cell_order_ids(sheet: &LoroMap) -> Vec { + let Ok(order) = cell_order_list(sheet) else { + return Vec::new(); + }; + movable_list_strings(&order) +} + +pub fn sheet_order_ids(doc: &LoroDoc) -> Vec { + movable_list_strings(&sheet_order(doc)) +} + +// ---- Cell rich text -------------------------------------------------------- + +/// Ensure the cell's flow container exists in the EXACT .db8 flow shape +/// (sentinel text container + attrs + text_container_id + a per-cell +/// paragraph registry child). +pub fn ensure_cell_flow(cell: &LoroMap) -> LoroResult { + let flow = ensure_flow(cell, CELL_FLOW_KEY, CELL_FLOW_KIND)?; + flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + Ok(flow) +} + +/// Canonical fresh-cell seed, mirroring `seed_document_body`: the boundary-0 +/// sentinel carrying the TAG paragraph-style mark plus the first paragraph +/// record (`ROOT_FIRST_PARAGRAPH_ID`) in the cell's own registry. Idempotent. +pub fn seed_cell_flow(cell: &LoroMap) -> LoroResult { + let flow = ensure_cell_flow(cell)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + if text.len_unicode() == 0 || !text.to_string().starts_with('\n') { + text.insert(0, "\n")?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(CELL_SEED_PARAGRAPH_STYLE))?; + } + let paragraphs = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + write_paragraph_record(¶graphs, &text, ROOT_FIRST_PARAGRAPH_ID, 0)?; + Ok(flow) +} + +/// Replace the cell's whole rich text with `paragraphs` (paste / import / +/// `ReplaceCellContent`): clear text + registry, one contiguous insert, merged +/// paragraph-style + run marks, one paragraph record per row (mirrors the +/// .db8 import plan's write shape at cell scale). +pub fn cell_flow_from_paragraphs(cell: &LoroMap, paragraphs: &[InputParagraph]) -> LoroResult { + let flow = ensure_cell_flow(cell)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let registry = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let len = text.len_unicode(); + if len > 0 { + text.delete(0, len)?; + } + registry.clear()?; + + if paragraphs.is_empty() { + // Converge on the canonical empty seed rather than a sentinel-less flow. + text.insert(0, "\n")?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(CELL_SEED_PARAGRAPH_STYLE))?; + write_paragraph_record(®istry, &text, ROOT_FIRST_PARAGRAPH_ID, 0)?; + return Ok(flow); + } + + // ONE contiguous insert for the whole flow text, then explicit mark ranges + // (the .db8 import-plan lesson: per-run inserts fragment text ids). + let mut full = String::new(); + let mut boundaries = Vec::with_capacity(paragraphs.len()); + for paragraph in paragraphs { + boundaries.push(full.chars().count()); + full.push('\n'); + for run in ¶graph.runs { + full.push_str(&run.text); + } + } + text.insert(0, &full)?; + + for (ix, paragraph) in paragraphs.iter().enumerate() { + let boundary = boundaries[ix]; + text.mark( + boundary..boundary + 1, + MARK_PARAGRAPH_STYLE, + paragraph_style_value(paragraph.style), + )?; + let mut unicode_pos = boundary + 1; + for run in ¶graph.runs { + let run_len = run.text.chars().count(); + if run_len > 0 { + mark_run_styles(&text, unicode_pos..unicode_pos + run_len, &run.styles)?; + } + unicode_pos += run_len; + } + let record_key = if ix == 0 { + ROOT_FIRST_PARAGRAPH_ID.to_string() + } else { + format!("paragraph.{}", Uuid::new_v4().as_u128()) + }; + write_paragraph_record(®istry, &text, &record_key, boundary)?; + } + Ok(flow) +} + +/// One durable paragraph record in a cell's registry: identity + boundary/start +/// cursors (start `Side::Left`, boundary `Side::Right` — the .db8 import shape). +pub fn write_paragraph_record(registry: &LoroMap, text: &LoroText, key: &str, boundary_pos: usize) -> LoroResult { + let record = registry.ensure_mergeable_map(key)?; + record.insert("id", key)?; + record.insert("flow_id", CELL_FLOW_KEY)?; + if let Some(cursor) = text.get_cursor(boundary_pos, Side::Left) { + record.insert("start_cursor", cursor.encode())?; + } + if let Some(cursor) = text.get_cursor(boundary_pos, Side::Right) { + record.insert("boundary_cursor", cursor.encode())?; + } + record.ensure_mergeable_map("attrs")?; + Ok(record) +} + +/// Apply `styles` as explicit marks over `range` (unicode). The inverse of +/// `run_styles_from_attrs`; only non-default fields emit marks. +pub fn mark_run_styles(text: &LoroText, range: std::ops::Range, styles: &gpui_flowtext::RunStyles) -> LoroResult<()> { + use flowstate_document::loro_schema::{MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, MARK_VERT_ALIGN}; + if let RunSemanticStyle::Custom(slot) = styles.semantic { + text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; + } + if let Some(gpui_flowtext::HighlightStyle::Custom(slot)) = styles.highlight { + text.mark(range.clone(), MARK_HIGHLIGHT_STYLE, i64::from(slot))?; + } + if styles.direct_underline { + text.mark(range.clone(), MARK_DIRECT_UNDERLINE, true)?; + } + if styles.strikethrough { + text.mark(range.clone(), MARK_STRIKETHROUGH, true)?; + } + if let Some(value) = styles.vert_align.mark_value() { + text.mark(range, MARK_VERT_ALIGN, value)?; + } + Ok(()) +} + +// ---- Annotations ---------------------------------------------------------- + +pub fn put_annotation(doc: &LoroDoc, stroke: &AnnotationStroke) -> anyhow::Result<()> { + annotations_map(doc).insert(&stroke.id.to_string(), postcard::to_allocvec(stroke)?)?; + Ok(()) +} + +pub fn delete_annotation(doc: &LoroDoc, stroke_id: StrokeId) -> LoroResult<()> { + let annotations = annotations_map(doc); + if annotations.get(&stroke_id.to_string()).is_some() { + annotations.delete(&stroke_id.to_string())?; + } + Ok(()) +} + +pub fn read_annotations(doc: &LoroDoc) -> Vec { + let mut strokes = Vec::new(); + annotations_map(doc).for_each(|key, value| { + if let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value + && let Ok(stroke) = postcard::from_bytes::(&bytes) + && key == stroke.id.to_string() + { + strokes.push(stroke); + } + }); + strokes.sort_by_key(|stroke| stroke.id); + strokes +} + +// ---- Shared readers -------------------------------------------------------- + +pub(crate) fn child_map(parent: &LoroMap, key: &str) -> Option { + match parent.get(key)? { + ValueOrContainer::Container(container) => container.into_map().ok(), + ValueOrContainer::Value(_) => None, + } +} + +pub(crate) fn map_string(map: &LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + } +} + +pub(crate) fn map_uuid(map: &LoroMap, key: &str) -> Option { + map_string(map, key).and_then(|value| Uuid::parse_str(&value).ok()) +} + +fn movable_list_strings(list: &LoroMovableList) -> Vec { + let mut ids = Vec::with_capacity(list.len()); + list.for_each(|value| { + if let ValueOrContainer::Value(LoroValue::String(id)) = value { + ids.push(id.to_string()); + } + }); + ids +} + +pub(crate) fn order_position(list: &LoroMovableList, id: &str) -> Option { + let mut position = None; + let mut ix = 0usize; + list.for_each(|value| { + if position.is_none() { + if let ValueOrContainer::Value(LoroValue::String(entry)) = value + && entry.as_str() == id + { + position = Some(ix); + } + ix += 1; + } + }); + position +} + +pub(crate) fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} + +fn unix_time_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) +} diff --git a/crates/flowstate-flow/src/operations.rs b/crates/flowstate-flow/src/operations.rs index 97bbe26f..f6acd10a 100644 --- a/crates/flowstate-flow/src/operations.rs +++ b/crates/flowstate-flow/src/operations.rs @@ -4,21 +4,7 @@ use anyhow::{Context as _, bail}; use uuid::Uuid; use crate::{AnnotationOriginator, AnnotationStroke, Cell, CellId, FlowDocument, FlowProjection, Sheet, SheetId, SheetTypeId}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RelativePosition { - Before, - After, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FlowDropIntent { - BeforeSibling(CellId), - AfterSibling(CellId), - FirstChildOf(CellId), - LastChildOf(CellId), - RootInColumn { column_index: usize, insertion_index: usize }, -} +pub use crate::intents::{FlowDropIntent, RelativePosition}; impl FlowDocument { pub fn child_append_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index 1365c986..4db6b2e9 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -9,9 +9,94 @@ use crate::document::FlowDocument; const MAGIC: &[u8; 8] = b"FLOWFL0\0"; const VERSION: u32 = 1; +/// The v2 container (spec Part A): same magic/framing, payload is the +/// zstd-compressed Loro snapshot of a v2 schema doc. v1 (the pre-release +/// record-blob format) is explicitly rejected — it never shipped. +pub const FL0_VERSION: u32 = 2; /// Refuse to read pathological on-disk sizes before allocating for them. const MAX_FL0_BYTES: u64 = 64 * 1024 * 1024; +/// Encode a raw Loro snapshot as v2 `.fl0` bytes. +#[must_use] +pub fn encode_fl0_snapshot(snapshot: &[u8]) -> Vec { + let compressed = zstd::stream::encode_all(snapshot, 3).expect("zstd encoding an in-memory buffer cannot fail"); + let mut bytes = Vec::with_capacity(MAGIC.len() + 12 + compressed.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&FL0_VERSION.to_le_bytes()); + bytes.extend_from_slice(&(compressed.len() as u64).to_le_bytes()); + bytes.extend_from_slice(&compressed); + bytes +} + +/// Decode v2 `.fl0` bytes back to the raw Loro snapshot. The caller imports it +/// into a fresh doc and validates `flow.meta/schema_version` (wrong-kind / +/// wrong-schema defense). +pub fn decode_fl0_snapshot(bytes: &[u8]) -> anyhow::Result> { + if bytes.len() as u64 > MAX_FL0_BYTES { + bail!("refusing to decode oversized .fl0 payload ({} bytes)", bytes.len()); + } + let mut cursor = Cursor::new(bytes); + let mut magic = [0; 8]; + cursor.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 signature").into()); + } + let version = read_u32(&mut cursor)?; + if version == 1 { + // v1 was a pre-release format that never shipped; there is nothing to + // migrate. Loud, specific message instead of a generic version error. + return Err(io::Error::new(io::ErrorKind::InvalidData, "this .fl0 uses the pre-release v1 format, which never shipped and cannot be opened").into()); + } + if version != FL0_VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported .fl0 version {version}")).into()); + } + let payload_len = read_u64(&mut cursor)? as usize; + if payload_len != bytes.len().saturating_sub(cursor.position() as usize) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 payload length").into()); + } + let mut compressed = Vec::with_capacity(payload_len); + cursor.read_to_end(&mut compressed)?; + Ok(zstd::stream::decode_all(compressed.as_slice())?) +} + +/// Read a v2 `.fl0` file (size-capped) and return the raw Loro snapshot. +pub fn read_fl0(path: impl AsRef) -> anyhow::Result> { + let path = path.as_ref(); + let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.len() > MAX_FL0_BYTES { + bail!( + "refusing to read {}: file too large ({} bytes > {} bytes)", + path.display(), + metadata.len(), + MAX_FL0_BYTES + ); + } + decode_fl0_snapshot(&fs::read(path)?) +} + +/// Atomically write a raw Loro snapshot as a v2 `.fl0` file +/// (write-then-rename, same law as the v1 saver). +pub fn write_fl0(path: impl AsRef, snapshot: &[u8]) -> anyhow::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let temp_path = path.with_file_name(format!( + ".{}.{}.tmp", + path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("flowstate"), + Uuid::new_v4() + )); + fs::write(&temp_path, encode_fl0_snapshot(snapshot)).with_context(|| format!("failed to write temporary {}", temp_path.display()))?; + fs::rename(&temp_path, path).with_context(|| format!("failed to atomically replace {} with {}", path.display(), temp_path.display()))?; + Ok(()) +} + pub fn load_flow_document(path: impl AsRef) -> anyhow::Result { let path = path.as_ref(); let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; diff --git a/crates/flowstate-flow/src/projection.rs b/crates/flowstate-flow/src/projection.rs new file mode 100644 index 00000000..c768b59c --- /dev/null +++ b/crates/flowstate-flow/src/projection.rs @@ -0,0 +1,93 @@ +//! The materialized flow BOARD projection (.fl0 v2 spec, Part A). +//! +//! What `DocumentProjection` is to a .db8 body, `FlowBoardProjection` is to a +//! flow board: a plain-data, deterministic materialization of canonical Loro +//! state. Cell rich text is NOT embedded — each cell carries a cached +//! [`CellSummary`] (the board renders summaries; the full per-cell +//! `DocumentProjection` is materialized on demand by the runtime when a cell +//! editor opens). + +use std::sync::Arc; + +use crate::format::{AnnotationStroke, CellId, ColumnId, FlowFormat, SheetId, SheetTypeId}; + +/// Board-visible digest of one cell's rich text, derived from the cell's +/// materialized rows (the former `Cell::summary_text` / +/// `uses_summary_projection` logic, computed once per text change). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CellSummary { + /// Tag/undertag/analytic rows + cite runs in document order, joined by + /// newlines; the full text when no summary rows exist. + pub summary_text: Arc, + /// Whether the cell renders through the summary projection (any summary + /// row or cite run present). + pub uses_summary_projection: bool, + /// Whether every (non-empty) run is struck through. + pub struck: bool, + /// Whether the cell has no visible text at all. + pub is_empty: bool, +} + +impl Default for CellSummary { + fn default() -> Self { + Self { + summary_text: Arc::from(""), + uses_summary_projection: false, + struck: false, + is_empty: true, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub id: CellId, + pub column_id: ColumnId, + pub parent_id: Option, + pub summary: CellSummary, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + pub id: SheetId, + pub name: String, + pub sheet_type_id: SheetTypeId, + /// Flat sheet order (the canonical DFS linearization: every subtree + /// contiguous, roots and siblings in order-list order). + pub cells: Vec, + pub annotations: Vec, +} + +impl Sheet { + #[must_use] + pub fn cell(&self, id: CellId) -> Option<&Cell> { + self.cells.iter().find(|cell| cell.id == id) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FlowBoardProjection { + pub format: FlowFormat, + pub sheets: Vec, +} + +impl FlowBoardProjection { + #[must_use] + pub fn sheet(&self, id: SheetId) -> Option<&Sheet> { + self.sheets.iter().find(|sheet| sheet.id == id) + } + + #[must_use] + pub fn sheet_mut(&mut self, id: SheetId) -> Option<&mut Sheet> { + self.sheets.iter_mut().find(|sheet| sheet.id == id) + } + + /// The sheet owning `cell`, plus the cell itself. + #[must_use] + pub fn cell(&self, cell: CellId) -> Option<(&Sheet, &Cell)> { + self + .sheets + .iter() + .find_map(|sheet| sheet.cell(cell).map(|found| (sheet, found))) + } +} diff --git a/crates/flowstate-flow/tests/v2_board.rs b/crates/flowstate-flow/tests/v2_board.rs new file mode 100644 index 00000000..60588e91 --- /dev/null +++ b/crates/flowstate-flow/tests/v2_board.rs @@ -0,0 +1,435 @@ +//! .fl0 v2 schema + materializer gates (build-order step 2): +//! import→materialize identity, normalization determinism under raw +//! concurrent mutation, cycle/orphan/run-split unit laws, and the headline +//! schema property — a reorder writes only the order list and can never +//! clobber a concurrent text edit. + +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::loro_projection::{FlowDefect, materialize_board, materialize_cell_rows, summary_from_rows}; +use flowstate_flow::loro_schema::{ + self, cell_flow_from_paragraphs, cell_map, init_flow_document, move_cell_order, read_document_id, read_schema_version, remove_cell, + remove_sheet, seed_cell_flow, sheet_map, write_cell, write_sheet, +}; +use flowstate_flow::{decode_fl0_snapshot, encode_fl0_snapshot}; +use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; +use loro::{ExportMode, LoroDoc}; +use uuid::Uuid; + +fn new_doc(format: &FlowFormat) -> LoroDoc { + let doc = LoroDoc::new(); + init_flow_document(&doc, format).expect("init flow doc"); + doc.commit(); + doc +} + +fn fork(doc: &LoroDoc) -> LoroDoc { + let snapshot = doc.export(ExportMode::Snapshot).expect("export snapshot"); + let forked = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&forked); + forked.import(&snapshot).expect("import snapshot"); + forked +} + +fn exchange(a: &LoroDoc, b: &LoroDoc) { + let for_b = a.export(ExportMode::updates(&b.oplog_vv())).expect("export a→b"); + let for_a = b.export(ExportMode::updates(&a.oplog_vv())).expect("export b→a"); + a.import(&for_a).expect("import into a"); + b.import(&for_b).expect("import into b"); +} + +fn boards_equal(a: &LoroDoc, b: &LoroDoc) { + let (board_a, _) = materialize_board(a).expect("materialize a"); + let (board_b, _) = materialize_board(b).expect("materialize b"); + assert_eq!(board_a, board_b, "peers sharing canonical state must materialize identical boards"); +} + +fn run(text: &str) -> InputRun { + InputRun { + text: text.into(), + styles: RunStyles::default(), + } +} + +fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { + InputParagraph { style, runs } +} + +struct Board { + doc: LoroDoc, + format: FlowFormat, + sheet: SheetId, +} + +fn board_with_sheet() -> Board { + let format = FlowFormat::policy_debate(); + let doc = new_doc(&format); + let sheet = Uuid::new_v4(); + write_sheet(&doc, sheet, "Case", format.sheet_types[0].id, 0).expect("write sheet"); + doc.commit(); + Board { doc, format, sheet } +} + +impl Board { + fn add_cell(&self, column_index: usize, parent: Option, order_index: usize) -> CellId { + let cell_id = Uuid::new_v4(); + let column = self.format.sheet_types[0].columns[column_index].id; + let cell = write_cell(&self.doc, self.sheet, cell_id, column, parent, order_index).expect("write cell"); + seed_cell_flow(&cell).expect("seed cell flow"); + self.doc.commit(); + cell_id + } +} + +#[test] +fn fresh_board_materializes_and_round_trips_through_snapshot() { + let board = board_with_sheet(); + let root = board.add_cell(0, None, usize::MAX); + let child = board.add_cell(1, Some(root), usize::MAX); + let cell = cell_map(&board.doc, root).expect("cell map"); + cell_flow_from_paragraphs( + &cell, + &[ + paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag line")]), + paragraph(ParagraphStyle::Normal, vec![run("body text")]), + ], + ) + .expect("write cell content"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "well-formed board materializes without defects"); + assert_eq!(projection.format, board.format); + assert_eq!(projection.sheets.len(), 1); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.name, "Case"); + assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![root, child]); + assert_eq!(sheet.cells[1].parent_id, Some(root)); + let summary = &sheet.cells[0].summary; + assert_eq!(summary.summary_text.as_ref(), "Tag line"); + assert!(summary.uses_summary_projection); + assert!(!summary.is_empty); + // The fresh child seeded with the canonical empty tag row. + assert!(sheet.cells[1].summary.is_empty); + assert!(sheet.cells[1].summary.uses_summary_projection); + + // Snapshot round trip (the join path): identical board on a fresh doc. + let restored = fork(&board.doc); + assert_eq!(read_schema_version(&restored), Some(2)); + assert_eq!(read_document_id(&restored), read_document_id(&board.doc)); + boards_equal(&board.doc, &restored); +} + +/// THE headline schema property: a reorder writes only the order list, so it +/// can never clobber a concurrent text edit in a moved cell. +#[test] +fn reorder_never_clobbers_a_concurrent_cell_edit() { + let board = board_with_sheet(); + let first = board.add_cell(0, None, usize::MAX); + let second = board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + + // Peer A moves `first` after `second` (order-list-only write). + let sheet_a = sheet_map(&peer_a, board.sheet).expect("sheet map"); + move_cell_order(&sheet_a, 0, 1).expect("move"); + peer_a.commit(); + + // Peer B concurrently replaces `first`'s text. + let cell_b = cell_map(&peer_b, first).expect("cell map"); + cell_flow_from_paragraphs(&cell_b, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("surviving edit")])]).expect("edit"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + + let (merged, defects) = materialize_board(&peer_a).expect("materialize merged"); + assert_eq!(defects, Vec::new()); + let sheet = &merged.sheets[0]; + assert_eq!( + sheet.cells.iter().map(|cell| cell.id).collect::>(), + vec![second, first], + "the reorder survived" + ); + assert_eq!( + sheet.cell(first).expect("moved cell").summary.summary_text.as_ref(), + "surviving edit", + "the concurrent text edit survived the reorder" + ); +} + +#[test] +fn concurrent_char_edits_in_one_cell_merge() { + let board = board_with_sheet(); + let cell_id = board.add_cell(0, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + cell_flow_from_paragraphs(&cell, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("shared")])]).expect("seed text"); + board.doc.commit(); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + let text_a = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_a, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + let text_b = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_b, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + // "\nshared": A prepends at the paragraph head, B appends at the tail. + text_a.insert(1, "A-").expect("insert a"); + peer_a.commit(); + text_b.insert(7, "-B").expect("insert b"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let rows = materialize_cell_rows(&peer_a, cell_id).expect("cell rows"); + let summary = summary_from_rows(&rows.blocks); + assert_eq!(summary.summary_text.as_ref(), "A-shared-B", "same-cell edits interleave char-level"); +} + +#[test] +fn cycle_break_orphans_the_greatest_uuid_member_deterministically() { + let board = board_with_sheet(); + let a = board.add_cell(0, None, usize::MAX); + let b = board.add_cell(1, Some(a), usize::MAX); + // Raw-write the cycle: a.parent = b (b is already a's child). + let cell_a = cell_map(&board.doc, a).expect("cell a"); + loro_schema::set_cell_parent(&cell_a, Some(b)).expect("cycle write"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + let broken = a.max(b); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::ParentCycle { cell } if *cell == broken)), + "greatest-uuid member reported: {defects:?}" + ); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.cell(broken).expect("broken cell").parent_id, None); + // Determinism: a fresh fork materializes the identical board. + boards_equal(&board.doc, &fork(&board.doc)); +} + +#[test] +fn dangling_parent_is_orphaned() { + let board = board_with_sheet(); + let cell_id = board.add_cell(1, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + loro_schema::set_cell_parent(&cell, Some(Uuid::new_v4())).expect("dangling write"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::DanglingParent { cell } if *cell == cell_id)) + ); + assert_eq!(projection.sheets[0].cell(cell_id).unwrap().parent_id, None); +} + +#[test] +fn column_adjacency_violation_orphans_the_child() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + // Child two columns right of its parent (adjacency requires exactly one). + let child = board.add_cell(2, Some(parent), usize::MAX); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::ColumnAdjacency { cell } if *cell == child)) + ); + assert_eq!(projection.sheets[0].cell(child).unwrap().parent_id, None); +} + +#[test] +fn split_sibling_run_relinearizes_to_canonical_dfs() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + let first_child = board.add_cell(1, Some(parent), usize::MAX); + let second_child = board.add_cell(1, Some(parent), usize::MAX); + let intruder = board.add_cell(1, None, usize::MAX); + // Raw order surgery: shove the parentless intruder between the two children. + let sheet = sheet_map(&board.doc, board.sheet).expect("sheet map"); + move_cell_order(&sheet, 3, 2).expect("split the run"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::SiblingRunSplit { sheet } if *sheet == board.sheet)) + ); + let cells: Vec = projection.sheets[0].cells.iter().map(|cell| cell.id).collect(); + assert_eq!( + cells, + vec![parent, first_child, second_child, intruder], + "canonical DFS keeps the subtree contiguous" + ); + boards_equal(&board.doc, &fork(&board.doc)); +} + +#[test] +fn concurrent_sheet_delete_and_cell_add_converge_on_sheet_delete() { + let board = board_with_sheet(); + board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + remove_sheet(&peer_a, board.sheet).expect("remove sheet"); + peer_a.commit(); + let orphan = Uuid::new_v4(); + let column = board.format.sheet_types[0].columns[0].id; + let cell = write_cell(&peer_b, board.sheet, orphan, column, None, usize::MAX).expect("concurrent add"); + seed_cell_flow(&cell).expect("seed"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let (projection, defects) = materialize_board(&peer_a).expect("materialize"); + // Loro map LWW: whichever way the sheet-record race resolves, both peers + // agree; with the delete winning the orphan cell is skipped + reported. + if projection.sheets.is_empty() { + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::CellRecordUnknownSheet { cell } if *cell == orphan)) + ); + } +} + +#[test] +fn delete_cell_removes_record_and_order() { + let board = board_with_sheet(); + let stays = board.add_cell(0, None, usize::MAX); + let goes = board.add_cell(0, None, usize::MAX); + remove_cell(&board.doc, board.sheet, goes).expect("remove"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "a clean delete leaves no liveness defect"); + assert_eq!( + projection.sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect::>(), + vec![stays] + ); +} + +/// Normalization determinism property: forked docs take random RAW mutations +/// (moves, parent rewrites, deletes racing adds — no invariant checked at +/// write time), cross-import, and must materialize byte-equal, defect-equal +/// boards; and materializing twice is a fixpoint. +#[test] +fn normalization_determinism_under_random_concurrent_raw_mutation() { + let mut rng = 0x1234_5678_9abc_def0_u64; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + + for round in 0..60 { + let board = board_with_sheet(); + let mut cells: Vec = Vec::new(); + for _ in 0..6 { + let column = (next() % 3) as usize; + let parent = if column > 0 && next() % 2 == 0 && !cells.is_empty() { + Some(cells[(next() % cells.len() as u64) as usize]) + } else { + None + }; + cells.push(board.add_cell(column, parent, usize::MAX)); + } + + let peers = [fork(&board.doc), fork(&board.doc), fork(&board.doc)]; + for peer in &peers { + for _ in 0..4 { + let choice = next() % 5; + let victim = cells[(next() % cells.len() as u64) as usize]; + match choice { + 0 => { + if let Some(sheet) = sheet_map(peer, board.sheet) { + let len = loro_schema::cell_order_ids(&sheet).len(); + if len > 1 { + let from = (next() % len as u64) as usize; + let to = (next() % len as u64) as usize; + let _ = move_cell_order(&sheet, from, to); + } + } + }, + 1 => { + if let Some(cell) = cell_map(peer, victim) { + let parent = cells[(next() % cells.len() as u64) as usize]; + let _ = loro_schema::set_cell_parent(&cell, Some(parent)); + } + }, + 2 => { + if let Some(cell) = cell_map(peer, victim) { + let column = board.format.sheet_types[0].columns[(next() % 4) as usize].id; + let _ = loro_schema::set_cell_column(&cell, column); + } + }, + 3 => { + let _ = remove_cell(peer, board.sheet, victim); + }, + _ => { + if let Some(cell) = cell_map(peer, victim) { + let _ = cell_flow_from_paragraphs( + &cell, + &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run(&format!("edit {round}"))])], + ); + } + }, + } + peer.commit(); + } + } + + // Full mesh exchange until quiescent (two rounds suffice for 3 peers). + for _ in 0..2 { + exchange(&peers[0], &peers[1]); + exchange(&peers[1], &peers[2]); + exchange(&peers[0], &peers[2]); + } + + let materialized: Vec<_> = peers + .iter() + .map(|peer| materialize_board(peer).expect("materialize")) + .collect(); + assert_eq!(materialized[0], materialized[1], "round {round}: peers 0/1 diverged"); + assert_eq!(materialized[1], materialized[2], "round {round}: peers 1/2 diverged"); + // Fixpoint: materializing again yields the identical board. + let again = materialize_board(&peers[0]).expect("materialize again"); + assert_eq!(materialized[0].0, again.0, "round {round}: materializer is not a pure function"); + } +} + +#[test] +fn fl0_v2_round_trips_and_v1_is_rejected() { + let board = board_with_sheet(); + board.add_cell(0, None, usize::MAX); + let snapshot = board.doc.export(ExportMode::Snapshot).expect("export"); + let encoded = encode_fl0_snapshot(&snapshot); + let decoded = decode_fl0_snapshot(&encoded).expect("decode"); + let restored = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&restored); + restored.import(&decoded).expect("import"); + assert_eq!(read_schema_version(&restored), Some(2)); + boards_equal(&board.doc, &restored); + + // v1 rejection: same magic, version 1. + let mut v1 = Vec::new(); + v1.extend_from_slice(b"FLOWFL0\0"); + v1.extend_from_slice(&1u32.to_le_bytes()); + v1.extend_from_slice(&0u64.to_le_bytes()); + let error = decode_fl0_snapshot(&v1).expect_err("v1 must be rejected"); + assert!(error.to_string().contains("never shipped"), "{error}"); +} From 6d0c7c2833f202fbdd75d4ec8f2a47d112174d56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:31:02 +0000 Subject: [PATCH 16/24] flowstate-collab: FlowRuntime, gated flow write path, FlowCellAuthority, flow I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .db8 local_write/crdt_runtime split mirrored at board scale (src/flow/): FlowRuntime behind the SAME generic WriteGate (board projection maintained in place per intent, per-open-cell materialization cache, ordered board + per-cell streams, publish queue, Loro UndoManager configured verbatim — remote/repair excluded, meta inert, FlowUndoMeta board context riding undo items via on_push/on_pop with stack-transformed cursors); commit.rs holds ALL frontier logic (resolve → mutate → ONE 'local' commit with class message → in-place derive → publish, revert_to compensation under 'repair'); cell_text.rs is the cell-scoped resolution law + paragraph-only executors; FlowCellAuthority implements LocalWriteAuthority per cell (whole-cell Replace streams, cursor-exact caret survival via encode/resolve_selection_anchor); FlowIoHandle is the DocIoService sibling with the §6.4 coalescer + byte budget, fork-under-gate exports, and .fl0 v2 save/recovery bytes. Debug builds audit every committed intent's maintained board against a fresh materialization — the audit caught (and the fix codified) the Sibling::After placement law under canonical DFS ordering. Gates: single-peer intents of every class, no-op/rejection zero-mutation, undo/redo ping-pong across classes, undo-meta focus restore, publish-queue convergence onto a second runtime, summary fixture parity. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- Cargo.lock | 3 + crates/flowstate-collab/Cargo.toml | 1 + .../src/flow/cell_authority.rs | 212 ++++++ crates/flowstate-collab/src/flow/cell_text.rs | 506 +++++++++++++++ crates/flowstate-collab/src/flow/commit.rs | 601 ++++++++++++++++++ crates/flowstate-collab/src/flow/flow_io.rs | 281 ++++++++ crates/flowstate-collab/src/flow/handle.rs | 176 +++++ crates/flowstate-collab/src/flow/mod.rs | 30 + crates/flowstate-collab/src/flow/runtime.rs | 552 ++++++++++++++++ crates/flowstate-collab/src/io_util.rs | 22 + crates/flowstate-collab/src/lib.rs | 2 + crates/flowstate-collab/tests/flow_runtime.rs | 494 ++++++++++++++ crates/flowstate-flow/src/loro_schema.rs | 34 +- crates/flowstate-flow/tests/v2_board.rs | 25 + 14 files changed, 2938 insertions(+), 1 deletion(-) create mode 100644 crates/flowstate-collab/src/flow/cell_authority.rs create mode 100644 crates/flowstate-collab/src/flow/cell_text.rs create mode 100644 crates/flowstate-collab/src/flow/commit.rs create mode 100644 crates/flowstate-collab/src/flow/flow_io.rs create mode 100644 crates/flowstate-collab/src/flow/handle.rs create mode 100644 crates/flowstate-collab/src/flow/mod.rs create mode 100644 crates/flowstate-collab/src/flow/runtime.rs create mode 100644 crates/flowstate-collab/src/io_util.rs create mode 100644 crates/flowstate-collab/tests/flow_runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 208e1d09..da87221e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3171,6 +3171,7 @@ dependencies = [ "dispatch2", "flowstate-document", "flowstate-fidelity", + "flowstate-flow", "futures-util", "gpui", "gpui-flowtext", @@ -3283,8 +3284,10 @@ version = "0.1.0" dependencies = [ "anyhow", "flowstate-document", + "gpui-flowtext", "loro", "postcard", + "rustc-hash 2.1.2", "serde", "uuid", "zstd", diff --git a/crates/flowstate-collab/Cargo.toml b/crates/flowstate-collab/Cargo.toml index 39a397fc..05420303 100644 --- a/crates/flowstate-collab/Cargo.toml +++ b/crates/flowstate-collab/Cargo.toml @@ -11,6 +11,7 @@ base64 = { workspace = true } blake3 = "1.8.5" flowstate-document.workspace = true flowstate-fidelity.workspace = true +flowstate-flow.workspace = true gpui-flowtext = { workspace = true } hotpath.workspace = true iroh = { workspace = true } diff --git a/crates/flowstate-collab/src/flow/cell_authority.rs b/crates/flowstate-collab/src/flow/cell_authority.rs new file mode 100644 index 00000000..51868a99 --- /dev/null +++ b/crates/flowstate-collab/src/flow/cell_authority.rs @@ -0,0 +1,212 @@ +//! `FlowCellAuthority` — a per-cell [`LocalWriteAuthority`] (flow spec Part B). +//! +//! Each open cell's `RichTextEditor` is driven by one of these through the +//! existing `RichTextEditor::set_write_authority` injection point. It +//! translates the editor's `LocalIntent`s into `FlowIntent::CellText` on the +//! shared flow handle, drains THAT cell's ordered stream, and provides exact +//! caret survival across concurrent same-cell edits via encoded Loro cursors +//! (`encode/resolve_selection_anchor`). Object/table/image intents are +//! rejected structurally — `flow_cell_surface` editors never emit them. + +use std::sync::Arc; + +use flowstate_flow::format::CellId; +use flowstate_flow::intents::FlowIntent; +use gpui_flowtext::local_intents::{ + IntentCounters, LocalCommit, LocalIntent, LocalWriteAuthority, LocalWriteOutcome, UndoOutcome, WriteRejected, +}; +use gpui_flowtext::{DocumentProjection, EditorSelection, ProjectionPatchBatch, ProjectionStreamItem}; +use loro::{ContainerTrait as _, cursor::Cursor}; +use uuid::Uuid; + +use super::cell_text::CellTextContext; +use super::handle::FlowDocHandle; +use super::runtime::{FlowUndoMeta, FlowWriteRejected}; +use crate::crdt_runtime::cursor_for_boundary; +use crate::local_write::GateHolder; + +pub struct FlowCellAuthority { + handle: Arc, + cell: CellId, +} + +impl FlowCellAuthority { + #[must_use] + pub fn new(handle: Arc, cell: CellId) -> Self { + Self { handle, cell } + } + + #[must_use] + pub fn cell_id(&self) -> CellId { + self.cell + } + + #[must_use] + pub fn handle(&self) -> &Arc { + &self.handle + } + + fn map_rejection(rejected: FlowWriteRejected) -> WriteRejected { + match rejected { + FlowWriteRejected::UnresolvedParagraph(id) => WriteRejected::UnresolvedParagraph(id), + FlowWriteRejected::UnresolvedCursor => WriteRejected::UnresolvedCursor, + FlowWriteRejected::EmptyIntent => WriteRejected::EmptyIntent, + FlowWriteRejected::GatePoisoned => WriteRejected::GatePoisoned, + FlowWriteRejected::CompensatedFailure { class, diagnostic } => WriteRejected::CompensatedFailure { class, diagnostic }, + FlowWriteRejected::CompensationFailed { class, diagnostic } => WriteRejected::CompensationFailed { class, diagnostic }, + FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_) => { + WriteRejected::StructureViolation("cell no longer exists in canonical flow state") + }, + FlowWriteRejected::StructureViolation(reason) => { + tracing::debug!(%reason, "cell write rejected as a structure violation"); + WriteRejected::StructureViolation("intent violates flow cell structure") + }, + } + } + + /// Resolve a restored undo/redo board context into THIS cell's selection + /// (only when the restored focus IS this cell). + fn selection_from_meta(&self, meta: &FlowUndoMeta) -> Option { + if meta.focused_cell != Some(self.cell) { + return None; + } + let (head_cursor, anchor_cursor) = (meta.head_cursor.as_deref()?, meta.anchor_cursor.as_deref()?); + let (head, anchor) = self.resolve_selection_anchor(head_cursor, anchor_cursor)?; + Some(EditorSelection { + anchor, + head, + anchor_affinity: gpui_flowtext::SelectionAffinity::Neutral, + head_affinity: gpui_flowtext::SelectionAffinity::Neutral, + anchor_gravity: gpui_flowtext::VisualGravity::Neutral, + head_gravity: gpui_flowtext::VisualGravity::Neutral, + }) + } +} + +impl LocalWriteAuthority for FlowCellAuthority { + fn apply(&self, intent: LocalIntent) -> Result { + let outcome = self + .handle + .apply(FlowIntent::CellText { + cell_id: self.cell, + intent, + }) + .map_err(Self::map_rejection)?; + if !outcome.changed { + return Err(WriteRejected::EmptyIntent); + } + // The projection change rides the cell's ordered stream (whole-cell + // Replace — cells are tiny); the returned batch carries only the frontier + // pair + the caret. The editor never applies the batch directly + // (`integrate_outcome` drains the stream), so an empty patch list is the + // honest representation. + Ok(LocalWriteOutcome::Committed(LocalCommit { + patches: ProjectionPatchBatch { + transaction_id: Uuid::new_v4().as_u128(), + base_frontier: Vec::new(), + new_frontier: outcome.frontier.clone(), + patches: Arc::from(Vec::new()), + }, + frontier: outcome.frontier, + version_vector: outcome.version_vector, + selection_after: outcome.selection_after, + counters: IntentCounters::default(), + })) + } + + fn undo(&self) -> Result { + let outcome = self.handle.undo().map_err(Self::map_rejection)?; + let selection = outcome.meta.as_ref().and_then(|meta| self.selection_from_meta(meta)); + Ok(UndoOutcome { + applied: outcome.applied, + selection, + }) + } + + fn redo(&self) -> Result { + let outcome = self.handle.redo().map_err(Self::map_rejection)?; + let selection = outcome.meta.as_ref().and_then(|meta| self.selection_from_meta(meta)); + Ok(UndoOutcome { + applied: outcome.applied, + selection, + }) + } + + fn undo_group_start(&self) -> Result { + self.handle.undo_group_start().map_err(Self::map_rejection) + } + + fn undo_group_end(&self) -> Result<(), WriteRejected> { + self.handle.undo_group_end().map_err(Self::map_rejection) + } + + fn drain_projection_stream(&self) -> Result, WriteRejected> { + self + .handle + .with_runtime(GateHolder::LocalIntent, |runtime| runtime.take_cell_stream(self.cell)) + .map_err(Self::map_rejection) + } + + fn canonical_projection(&self) -> Result { + self.handle.open_cell(self.cell).map_err(Self::map_rejection) + } + + fn encode_selection_anchor(&self, selection: &EditorSelection, editor_frontier: &[u8]) -> Option<(Vec, Vec)> { + let cell = self.cell; + let selection = selection.clone(); + let editor_frontier = editor_frontier.to_vec(); + let handle = Arc::clone(&self.handle); + let encoded = self + .handle + .with_runtime(GateHolder::Presence, move |runtime| { + // Only encode while the editor is in sync with the core (an undrained + // import would anchor the caret against a stale projection). + if runtime.frontier() != editor_frontier { + return None; + } + let ctx = CellTextContext::resolve(runtime.doc(), cell).ok()?; + let head_unicode = ctx.unicode_for_offset(selection.head)?; + let anchor_unicode = ctx.unicode_for_offset(selection.anchor)?; + let head = cursor_for_boundary(&ctx.text, head_unicode, crate::presence::SelectionAffinity::Neutral)?; + let anchor = cursor_for_boundary(&ctx.text, anchor_unicode, crate::presence::SelectionAffinity::Neutral)?; + // Keep the flow undo context current: this is called at synced + // moments, exactly when the selection is a trustworthy capture. + let active_sheet = runtime.board_ref().cell(cell).map(|(sheet, _)| sheet.id); + handle.set_undo_context(FlowUndoMeta { + active_sheet, + focused_cell: Some(cell), + head_cursor: Some(head.encode()), + anchor_cursor: Some(anchor.encode()), + }); + Some((head.encode(), anchor.encode())) + }) + .ok()?; + encoded + } + + fn resolve_selection_anchor( + &self, + head_cursor: &[u8], + anchor_cursor: &[u8], + ) -> Option<(gpui_flowtext::DocumentOffset, gpui_flowtext::DocumentOffset)> { + let cell = self.cell; + let head_cursor = head_cursor.to_vec(); + let anchor_cursor = anchor_cursor.to_vec(); + self + .handle + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = CellTextContext::resolve(runtime.doc(), cell).ok()?; + let resolve = |encoded: &[u8]| { + let cursor = Cursor::decode(encoded).ok()?; + if cursor.container != ctx.text.id() { + return None; + } + let position = runtime.doc().get_cursor_pos(&cursor).ok()?; + ctx.offset_for_unicode(position.current.pos) + }; + Some((resolve(&head_cursor)?, resolve(&anchor_cursor)?)) + }) + .ok() + .flatten() + } +} diff --git a/crates/flowstate-collab/src/flow/cell_text.rs b/crates/flowstate-collab/src/flow/cell_text.rs new file mode 100644 index 00000000..0fd8f414 --- /dev/null +++ b/crates/flowstate-collab/src/flow/cell_text.rs @@ -0,0 +1,506 @@ +//! Cell-scoped rich-text resolution + executors (flow spec Part B). +//! +//! The .db8 resolution law, applied to ONE cell's flow: identity + cursor over +//! hint, reject before mutation. Cells are paragraph-only and tiny, so the +//! context is a full (O(cell)) snapshot of the live text — boundaries are the +//! `\n` positions, paragraph `i` spans `(boundary[i], boundary[i+1])`. +//! Executors mutate the live containers WITHOUT committing; the flow commit +//! path owns the single commit and the compensation law. + +use anyhow::{Context as _, Result}; +use flowstate_document::loro_schema::{MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH}; +use flowstate_document::paragraph_ids_by_boundary_in; +use flowstate_flow::format::CellId; +use flowstate_flow::loro_schema::{cell_paragraph_registry, cell_text, write_paragraph_record}; +use gpui_flowtext::local_intents::{FragmentBlock, LocalIntent, TextAnchor}; +use gpui_flowtext::{InputParagraph, ParagraphId, ParagraphStyle, RunStyles}; +use loro::{ContainerTrait as _, LoroDoc, LoroMap, LoroText, cursor::Cursor}; +use rustc_hash::FxHashMap; +use uuid::Uuid; + +use super::runtime::FlowWriteRejected; +use crate::crdt_runtime::sentinel_protected_delete_range; + +/// The live-text context one cell-text intent resolves against. +pub(crate) struct CellTextContext { + pub(crate) text: LoroText, + pub(crate) registry: LoroMap, + /// The cell's full text, char-indexed (cells are tiny by design). + chars: Vec, + /// `\n` boundary positions, ascending — one per paragraph row. + boundaries: Vec, + /// boundary unicode position → durable registry record key. + record_keys: FxHashMap, +} + +impl CellTextContext { + pub(crate) fn resolve(doc: &LoroDoc, cell: CellId) -> Result { + let text = cell_text(doc, cell).ok_or(FlowWriteRejected::UnknownCell(cell))?; + let registry = cell_paragraph_registry(doc, cell).ok_or(FlowWriteRejected::UnknownCell(cell))?; + let chars: Vec = text.to_string().chars().collect(); + let boundaries: Vec = chars + .iter() + .enumerate() + .filter_map(|(ix, ch)| (*ch == '\n').then_some(ix)) + .collect(); + let record_keys = paragraph_ids_by_boundary_in(doc, ®istry, &text); + Ok(Self { + text, + registry, + chars, + boundaries, + record_keys, + }) + } + + fn len_unicode(&self) -> usize { + self.chars.len() + } + + fn paragraph_count(&self) -> usize { + self.boundaries.len() + } + + /// Paragraph text span `(start, end)` in unicode positions (exclusive end). + fn paragraph_span(&self, ix: usize) -> Option<(usize, usize)> { + let start = self.boundaries.get(ix)? + 1; + let end = self + .boundaries + .get(ix + 1) + .copied() + .unwrap_or(self.len_unicode()); + Some((start, end)) + } + + fn paragraph_text(&self, ix: usize) -> Option { + let (start, end) = self.paragraph_span(ix)?; + Some(self.chars[start..end].iter().collect()) + } + + /// Map a `(paragraph_ix, byte)` render offset to a live unicode position. + pub(crate) fn unicode_for_offset(&self, offset: gpui_flowtext::DocumentOffset) -> Option { + let (start, end) = self.paragraph_span(offset.paragraph)?; + let mut unicode = start; + let mut bytes = 0usize; + for ch in &self.chars[start..end] { + if bytes >= offset.byte { + break; + } + bytes += ch.len_utf8(); + unicode += 1; + } + Some(unicode) + } + + /// Map a live unicode position back to `(paragraph_ix, byte)` render space. + pub(crate) fn offset_for_unicode(&self, unicode: usize) -> Option { + let ix = self + .boundaries + .partition_point(|boundary| *boundary < unicode) + .checked_sub(1)?; + let (start, end) = self.paragraph_span(ix)?; + let clamped = unicode.clamp(start, end); + let byte = self.chars[start..clamped].iter().map(|ch| ch.len_utf8()).sum(); + Some(gpui_flowtext::DocumentOffset { paragraph: ix, byte }) + } + + /// The resolution law: cursor basis first (must address THIS cell's text + /// container — foreign cursors fall back), then paragraph identity + clamped + /// byte hint. + fn resolve_anchor(&self, doc: &LoroDoc, paragraph_ids: &[ParagraphId], anchor: &TextAnchor) -> Result { + if let Some(encoded) = &anchor.cursor + && let Ok(cursor) = Cursor::decode(encoded) + && cursor.container == self.text.id() + && let Ok(position) = doc.get_cursor_pos(&cursor) + { + let pos = if cursor.id.is_some() { + position.current.pos + } else { + // Degraded id-less cursor: resolves to 0 or the whole-text end. + position.current.pos.min(self.len_unicode()) + }; + // Clamp out of the sentinel: position 0 is never a valid caret. + return Ok(pos.max(1).min(self.len_unicode())); + } + let ix = paragraph_ids + .iter() + .position(|id| *id == anchor.paragraph) + .filter(|ix| *ix < self.paragraph_count()) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + let text = self + .paragraph_text(ix) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + let mut byte = anchor.byte_hint.min(text.len()); + while byte > 0 && !text.is_char_boundary(byte) { + byte -= 1; + } + let (start, _) = self + .paragraph_span(ix) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + Ok(start + text[..byte].chars().count()) + } + + fn resolve_range( + &self, + doc: &LoroDoc, + paragraph_ids: &[ParagraphId], + start: &TextAnchor, + end: &TextAnchor, + ) -> Result<(usize, usize), FlowWriteRejected> { + let a = self.resolve_anchor(doc, paragraph_ids, start)?; + let b = self.resolve_anchor(doc, paragraph_ids, end)?; + Ok((a.min(b), a.max(b))) + } + + fn boundary_of_paragraph(&self, paragraph_ids: &[ParagraphId], id: ParagraphId) -> Result<(usize, usize), FlowWriteRejected> { + let ix = paragraph_ids + .iter() + .position(|candidate| *candidate == id) + .filter(|ix| *ix < self.paragraph_count()) + .ok_or(FlowWriteRejected::UnresolvedParagraph(id))?; + Ok((ix, self.boundaries[ix])) + } +} + +/// The fully-resolved cell-text execution plan (mutation-free until execute). +pub(crate) enum CellPlan { + Insert { + at: usize, + text: String, + style_override: Option, + }, + Delete { + start: usize, + len: usize, + retire: Vec, + }, + Split { + at: usize, + style: ParagraphStyle, + }, + Join { + boundary: usize, + retire: Option, + }, + Marks { + start: usize, + end: usize, + styles: RunStyles, + }, + ParagraphStyles { + boundaries: Vec, + style: ParagraphStyle, + }, + Replaces { + /// `(start, end, styles)`, sorted DESCENDING by start; same-paragraph, + /// non-overlapping. + matches: Vec<(usize, usize, Option)>, + replacement: String, + }, + Fragment { + at: usize, + paragraphs: Vec, + }, +} + +/// Resolve one editor intent against the cell context. Rejection = zero +/// mutation (I-15). +pub(crate) fn resolve_cell_plan( + doc: &LoroDoc, + ctx: &CellTextContext, + paragraph_ids: &[ParagraphId], + intent: &LocalIntent, +) -> Result { + match intent { + LocalIntent::InsertText(intent) => { + if intent.text.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + if intent.text.contains('\n') { + return Err(FlowWriteRejected::StructureViolation( + "cell text inserts are single-paragraph; splits are explicit intents".into(), + )); + } + Ok(CellPlan::Insert { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + text: intent.text.clone(), + style_override: intent.style_override, + }) + }, + LocalIntent::DeleteRange(intent) => { + let (start, end) = ctx.resolve_range(doc, paragraph_ids, &intent.start, &intent.end)?; + let (start, len) = sentinel_protected_delete_range(start, end.saturating_sub(start)).ok_or(FlowWriteRejected::EmptyIntent)?; + let retire = ctx + .boundaries + .iter() + .filter(|boundary| (start..start + len).contains(boundary)) + .filter_map(|boundary| ctx.record_keys.get(boundary).cloned()) + .collect(); + Ok(CellPlan::Delete { start, len, retire }) + }, + LocalIntent::SplitParagraph(intent) => Ok(CellPlan::Split { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + style: intent.inherited_style, + }), + LocalIntent::JoinParagraphs(intent) => { + let (first_ix, _) = ctx.boundary_of_paragraph(paragraph_ids, intent.first)?; + let (second_ix, second_boundary) = ctx.boundary_of_paragraph(paragraph_ids, intent.second)?; + if second_ix != first_ix + 1 { + return Err(FlowWriteRejected::StructureViolation( + "join targets are no longer adjacent paragraphs".into(), + )); + } + Ok(CellPlan::Join { + boundary: second_boundary, + retire: ctx.record_keys.get(&second_boundary).cloned(), + }) + }, + LocalIntent::SetMarks(intent) => { + let (start, end) = ctx.resolve_range(doc, paragraph_ids, &intent.start, &intent.end)?; + if start == end { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Marks { + start, + end, + styles: intent.styles, + }) + }, + LocalIntent::SetParagraphStyle(intent) => { + let (_, boundary) = ctx.boundary_of_paragraph(paragraph_ids, intent.paragraph)?; + Ok(CellPlan::ParagraphStyles { + boundaries: vec![boundary], + style: intent.style, + }) + }, + LocalIntent::SetParagraphStyles(intent) => { + // Stale targets are skipped, not rejected (the .db8 batch law). + let boundaries: Vec = intent + .paragraphs + .iter() + .filter_map(|paragraph| ctx.boundary_of_paragraph(paragraph_ids, *paragraph).ok()) + .map(|(_, boundary)| boundary) + .collect(); + if boundaries.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::ParagraphStyles { + boundaries, + style: intent.style, + }) + }, + LocalIntent::ReplaceMatches(intent) => { + if intent.replacement.contains('\n') { + return Err(FlowWriteRejected::StructureViolation("replacement must not contain structural characters".into())); + } + let mut matches: Vec<(usize, usize, Option)> = Vec::new(); + for candidate in &intent.matches { + let Ok((start, end)) = ctx.resolve_range(doc, paragraph_ids, &candidate.start, &candidate.end) else { + continue; + }; + // Same-paragraph, non-collapsed: a range crossing a boundary was moved + // by concurrent edits — skip, don't reject. + if start == end || ctx.boundaries.iter().any(|boundary| (start..end).contains(boundary)) { + continue; + } + matches.push((start, end, candidate.styles)); + } + matches.sort_by(|a, b| b.0.cmp(&a.0)); + // Descending by start: drop a match that overlaps the previously kept + // (higher-positioned) one — its end reaches past that match's start. + matches.dedup_by(|current, previous| current.1 > previous.0); + if matches.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Replaces { + matches, + replacement: intent.replacement.clone(), + }) + }, + LocalIntent::InsertRichFragment(intent) => { + let mut paragraphs = Vec::with_capacity(intent.blocks.len()); + for block in &intent.blocks { + match block { + FragmentBlock::Paragraph(paragraph) => paragraphs.push(paragraph.clone()), + FragmentBlock::Object(_) => { + return Err(FlowWriteRejected::StructureViolation("cell flows are paragraph-only".into())); + }, + } + } + if paragraphs.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Fragment { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + paragraphs, + }) + }, + LocalIntent::InsertObject(_) + | LocalIntent::ReplaceObject(_) + | LocalIntent::DeleteBlocks(_) + | LocalIntent::MoveBlock(_) + | LocalIntent::ReplaceEquationSourceRange(_) + | LocalIntent::ReplaceImageAltText(_) + | LocalIntent::ReplaceImageCaption(_) + | LocalIntent::SetImageLayout(_) + | LocalIntent::Table(_) => Err(FlowWriteRejected::StructureViolation("cell flows are paragraph-only".into())), + } +} + +/// Execute a resolved plan against the live containers. Errors here trigger +/// the commit path's compensation (`revert_to`, origin `"repair"`). +pub(crate) fn execute_cell_plan(ctx: &CellTextContext, plan: &CellPlan) -> Result> { + match plan { + CellPlan::Insert { at, text, style_override } => { + ctx.text.insert(*at, text).context("inserting cell text")?; + let inserted = text.chars().count(); + if let Some(styles) = style_override + && inserted > 0 + { + replace_run_styles(&ctx.text, *at..*at + inserted, *styles)?; + } + Ok(Some(at + inserted)) + }, + CellPlan::Delete { start, len, retire } => { + ctx + .text + .delete(*start, *len) + .context("deleting cell text range")?; + for key in retire { + if ctx.registry.get(key).is_some() { + ctx + .registry + .delete(key) + .context("retiring merged-away cell paragraph record")?; + } + } + Ok(Some(*start)) + }, + CellPlan::Split { at, style } => { + ctx.text.insert(*at, "\n").context("inserting cell split boundary")?; + ctx + .text + .mark(*at..*at + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) + .context("marking cell split style")?; + // Sentinel hygiene: strip expand-`After` run keys off the boundary. + for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { + ctx.text.unmark(*at..*at + 1, key).context("unmarking split boundary run keys")?; + } + let key = format!("paragraph.{}", Uuid::new_v4().as_u128()); + write_paragraph_record(&ctx.registry, &ctx.text, &key, *at).context("writing split paragraph record")?; + Ok(Some(at + 1)) + }, + CellPlan::Join { boundary, retire } => { + ctx + .text + .delete(*boundary, 1) + .context("deleting join boundary")?; + if let Some(key) = retire + && ctx.registry.get(key).is_some() + { + ctx + .registry + .delete(key) + .context("retiring joined-away paragraph record")?; + } + Ok(Some(*boundary)) + }, + CellPlan::Marks { start, end, styles } => { + replace_run_styles(&ctx.text, *start..*end, *styles)?; + Ok(None) + }, + CellPlan::ParagraphStyles { boundaries, style } => { + for boundary in boundaries { + ctx + .text + .mark(*boundary..*boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) + .context("marking cell paragraph style")?; + } + Ok(None) + }, + CellPlan::Replaces { matches, replacement } => { + let mut caret = None; + for (start, end, styles) in matches { + ctx + .text + .delete(*start, end - start) + .context("deleting replaced match")?; + if !replacement.is_empty() { + ctx + .text + .insert(*start, replacement) + .context("inserting match replacement")?; + let len = replacement.chars().count(); + if let Some(styles) = styles { + replace_run_styles(&ctx.text, *start..*start + len, *styles)?; + } + caret = Some(start + len); + } else { + caret = Some(*start); + } + } + Ok(caret) + }, + CellPlan::Fragment { at, paragraphs } => { + let mut pos = *at; + for (ix, paragraph) in paragraphs.iter().enumerate() { + if ix > 0 { + ctx + .text + .insert(pos, "\n") + .context("inserting fragment boundary")?; + ctx + .text + .mark(pos..pos + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style)) + .context("marking fragment paragraph style")?; + let key = format!("paragraph.{}", Uuid::new_v4().as_u128()); + write_paragraph_record(&ctx.registry, &ctx.text, &key, pos).context("writing fragment paragraph record")?; + pos += 1; + } + for run in ¶graph.runs { + let len = run.text.chars().count(); + if len == 0 { + continue; + } + ctx + .text + .insert(pos, &run.text) + .context("inserting fragment run")?; + if run.styles != RunStyles::default() { + replace_run_styles(&ctx.text, pos..pos + len, run.styles)?; + } + pos += len; + } + } + Ok(Some(pos)) + }, + } +} + +/// Unmark-then-set over a range (the .db8 `mark_run_styles` law: SetMarks is a +/// full restatement of the range's run styles). +fn replace_run_styles(text: &LoroText, range: std::ops::Range, styles: RunStyles) -> Result<()> { + for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { + text.unmark(range.clone(), key).context("unmarking run style key")?; + } + if let gpui_flowtext::RunSemanticStyle::Custom(slot) = styles.semantic { + text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; + } + if let Some(gpui_flowtext::HighlightStyle::Custom(slot)) = styles.highlight { + text.mark(range.clone(), MARK_HIGHLIGHT_STYLE, i64::from(slot))?; + } + if styles.direct_underline { + text.mark(range.clone(), MARK_DIRECT_UNDERLINE, true)?; + } + if styles.strikethrough { + text.mark(range, MARK_STRIKETHROUGH, true)?; + } + Ok(()) +} + +pub(crate) fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} diff --git a/crates/flowstate-collab/src/flow/commit.rs b/crates/flowstate-collab/src/flow/commit.rs new file mode 100644 index 00000000..5e98aa58 --- /dev/null +++ b/crates/flowstate-collab/src/flow/commit.rs @@ -0,0 +1,601 @@ +//! Flow intent execution: resolve → mutate → commit → derive → publish (the +//! .db8 commit law at board scale). ALL frontier logic for the flow write path +//! lives in this file. +//! +//! Phases, verbatim from `local_write/commit.rs`: +//! 1. **Resolve** against the maintained board + live containers; any failure +//! rejects the intent before the doc is touched (I-15). +//! 2. **Mutate** through the `flowstate_flow::loro_schema` writers / the +//! cell-text executors. A mid-apply error triggers the I-10 compensation +//! (`revert_to` + `"repair"`-origin commits), never a partial escape. +//! 3. **Commit** exactly once — origin `"local"`, message = intent class. +//! 4. **Derive** the projection change in place (structural intents mutate the +//! maintained board directly; content intents rematerialize ONE cell) and +//! push the ordered streams; keystrokes touch the board stream only when +//! the cell's summary actually changed. +//! 5. **Publish**: export `updates(vv_before)` onto the publish queue. +//! +//! One intent = one Loro commit = one undo-group member. + +use anyhow::{Context as _, Result}; +use flowstate_flow::format::{CellId, SheetId}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowIntent, RelativePosition}; +use flowstate_flow::projection::{Cell, Sheet}; +use flowstate_flow::{board_ops, loro_schema}; +use gpui_flowtext::{CursorEndpoint, SelectionAffinity, SelectionSnapshot, VisualGravity}; +use loro::LoroDoc; +use uuid::Uuid; + +use super::cell_text::{self, CellTextContext}; +use super::runtime::{FlowRuntime, FlowWriteOutcome, FlowWriteRejected}; +use crate::crdt_runtime::cursor_for_boundary; + +/// Execute one flow intent against the gate-held runtime. +pub(crate) fn apply_flow_intent(core: &mut FlowRuntime, intent: &FlowIntent) -> Result { + let class = intent.class(); + let doc = core.doc().clone(); + let frontier_before = doc.state_frontiers(); + let vv_before = doc.oplog_vv(); + + // ---- Phases 1+2: resolve (reject before mutation), then mutate ------------ + let derived = match execute_intent(core, &doc, intent) { + Ok(Some(derived)) => derived, + // Resolved to a genuine no-op: nothing was mutated, nothing to commit. + Ok(None) => { + return Ok(FlowWriteOutcome { + changed: false, + frontier: core.frontier(), + version_vector: doc.state_vv().encode(), + selection_after: None, + }); + }, + Err(ExecuteError::Rejected(rejected)) => return Err(rejected), + Err(ExecuteError::MidApply(error)) => return Err(compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error)), + }; + + // ---- Phase 3: one commit, origin local, class message --------------------- + doc.set_next_commit_origin("local"); + doc.set_next_commit_message(class); + doc.commit(); + + // ---- Phase 4: derive + streams -------------------------------------------- + let mut selection_after = None; + match derived { + Derived::Board => core.push_board_stream(), + Derived::Content { cell, caret } => { + let summary_changed = core + .refresh_cell(cell) + .map_err(|error| compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error))?; + if summary_changed { + core.push_board_stream(); + } + selection_after = caret.and_then(|caret| cell_selection_snapshot(&doc, cell, caret)); + }, + Derived::BoardAndContent { cell } => { + // Structural change already applied to the maintained board; fold in the + // (possibly fresh) cell summary, then one board Replace. + let _ = core + .refresh_cell(cell) + .map_err(|error| compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error))?; + core.push_board_stream(); + }, + } + + // ---- Phase 5: publish ------------------------------------------------------ + core.queue_local_update_publish(&vv_before); + + #[cfg(debug_assertions)] + core.audit_board_against_rebuild(class); + + Ok(FlowWriteOutcome { + changed: true, + frontier: core.frontier(), + version_vector: doc.state_vv().encode(), + selection_after, + }) +} + +/// What the committed intent changed, for stream derivation. +enum Derived { + /// Structural board change (already applied to the maintained board). + Board, + /// One cell's rich text changed (cell rematerialization + summary check). + Content { cell: CellId, caret: Option }, + /// Structural board change PLUS one cell's content (AddCell). + BoardAndContent { cell: CellId }, +} + +enum ExecuteError { + Rejected(FlowWriteRejected), + MidApply(anyhow::Error), +} + +impl From for ExecuteError { + fn from(rejected: FlowWriteRejected) -> Self { + Self::Rejected(rejected) + } +} + +impl From for ExecuteError { + fn from(error: anyhow::Error) -> Self { + Self::MidApply(error) + } +} + +fn sheet_of<'a>(core: &'a FlowRuntime, sheet_id: SheetId) -> Result<&'a Sheet, FlowWriteRejected> { + core + .board_ref() + .sheet(sheet_id) + .ok_or(FlowWriteRejected::UnknownSheet(sheet_id)) +} + +/// Resolve + mutate. `Ok(None)` = resolved no-op (zero mutation). The board is +/// updated in place for structural intents (the derive step's authority). +#[allow(clippy::too_many_lines, reason = "one arm per intent class; each arm is short and self-contained")] +fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> Result, ExecuteError> { + match intent { + FlowIntent::CreateSheet { + sheet_id, + name, + sheet_type_id, + } => { + if core.board_ref().format.sheet_type(*sheet_type_id).is_none() { + return Err(FlowWriteRejected::StructureViolation("unknown sheet type".into()).into()); + } + if core.board_ref().sheet(*sheet_id).is_some() { + return Err(FlowWriteRejected::StructureViolation("sheet id already exists".into()).into()); + } + loro_schema::write_sheet(doc, *sheet_id, name, *sheet_type_id, usize::MAX).context("writing sheet record")?; + core.board_mut().sheets.push(Sheet { + id: *sheet_id, + name: name.clone(), + sheet_type_id: *sheet_type_id, + cells: Vec::new(), + annotations: Vec::new(), + }); + Ok(Some(Derived::Board)) + }, + FlowIntent::RenameSheet { sheet_id, name } => { + sheet_of(core, *sheet_id)?; + loro_schema::rename_sheet(doc, *sheet_id, name).context("renaming sheet")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.name = name.clone(); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteSheet { sheet_id } => { + sheet_of(core, *sheet_id)?; + loro_schema::remove_sheet(doc, *sheet_id).context("removing sheet")?; + let cells: Vec = sheet_of(core, *sheet_id) + .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) + .unwrap_or_default(); + for cell in cells { + core.close_cell(cell); + } + core.board_mut().sheets.retain(|sheet| sheet.id != *sheet_id); + Ok(Some(Derived::Board)) + }, + FlowIntent::MoveSheet { sheet_id, target_index } => { + sheet_of(core, *sheet_id)?; + loro_schema::move_sheet(doc, *sheet_id, *target_index).context("moving sheet")?; + let board = core.board_mut(); + let from = board + .sheets + .iter() + .position(|sheet| sheet.id == *sheet_id) + .expect("sheet resolved above"); + let sheet = board.sheets.remove(from); + let to = (*target_index).min(board.sheets.len()); + board.sheets.insert(to, sheet); + Ok(Some(Derived::Board)) + }, + FlowIntent::AddCell { + sheet_id, + cell_id, + placement, + seed, + } => { + let sheet = sheet_of(core, *sheet_id)?; + if core.board_ref().cell(*cell_id).is_some() { + return Err(FlowWriteRejected::StructureViolation("cell id already exists".into()).into()); + } + let columns = board_ops::sheet_column_ids(core.board_ref(), *sheet_id) + .map_err(|_| FlowWriteRejected::StructureViolation("unknown sheet type".into()))?; + let (column_index, insertion_index, parent) = resolve_placement(core, sheet.id, &columns, placement)?; + let column_id = columns[column_index]; + let cell_map = loro_schema::write_cell(doc, *sheet_id, *cell_id, column_id, parent, insertion_index).context("writing cell record")?; + match seed { + CellSeed::Empty => { + loro_schema::seed_cell_flow(&cell_map).context("seeding cell flow")?; + }, + CellSeed::Paragraphs(paragraphs) => { + loro_schema::cell_flow_from_paragraphs(&cell_map, paragraphs).context("writing seeded cell content")?; + }, + } + let summary = flowstate_flow::loro_projection::materialize_cell_rows(doc, *cell_id) + .map(|rows| flowstate_flow::loro_projection::summary_from_rows(&rows.blocks)) + .unwrap_or_default(); + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + let index = insertion_index.min(sheet.cells.len()); + sheet.cells.insert( + index, + Cell { + id: *cell_id, + column_id, + parent_id: parent, + summary, + }, + ); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteCell { sheet_id, cell_id } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + // Canonical child orphaning IN the delete commit (the old projection + // law), so the materializer never has to normalize a dangling parent. + let children: Vec = sheet + .cells + .iter() + .filter(|cell| cell.parent_id == Some(*cell_id)) + .map(|cell| cell.id) + .collect(); + for child in &children { + let map = loro_schema::cell_map(doc, *child).context("resolving child cell for orphaning")?; + loro_schema::set_cell_parent(&map, None).context("orphaning child cell")?; + } + loro_schema::remove_cell(doc, *sheet_id, *cell_id).context("removing cell record")?; + core.close_cell(*cell_id); + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.cells.retain(|cell| cell.id != *cell_id); + for cell in &mut sheet.cells { + if cell.parent_id == Some(*cell_id) { + cell.parent_id = None; + } + } + } + Ok(Some(Derived::Board)) + }, + FlowIntent::MoveCellSubtree { sheet_id, cell_id, drop } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + let columns = board_ops::sheet_column_ids(core.board_ref(), *sheet_id) + .map_err(|_| FlowWriteRejected::StructureViolation("unknown sheet type".into()))?; + // ONE law for preview and commit: the same pure move the drag preview + // ran, applied to a clone, then translated into order-list moves + + // column/parent rewrites. + let mut target = sheet.clone(); + board_ops::apply_move_subtree(&mut target, &columns, *cell_id, *drop) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}")))?; + if !board_ops::sheet_topology_ok(&target, &columns) { + return Err(FlowWriteRejected::StructureViolation("move would violate sheet topology".into()).into()); + } + let before = sheet.clone(); + let sheet_map = loro_schema::sheet_map(doc, *sheet_id).context("resolving sheet map")?; + // Column/parent rewrites for changed subtree members (LWW fields). + for cell in &target.cells { + let old = before.cell(cell.id).context("moved cell missing from pre-state")?; + if old.column_id != cell.column_id || old.parent_id != cell.parent_id { + let map = loro_schema::cell_map(doc, cell.id).context("resolving moved cell map")?; + if old.column_id != cell.column_id { + loro_schema::set_cell_column(&map, cell.column_id).context("rewriting moved cell column")?; + } + if old.parent_id != cell.parent_id { + loro_schema::set_cell_parent(&map, cell.parent_id).context("rewriting moved cell parent")?; + } + } + } + // Order-list moves ONLY (never a cell map/text write): minimal `mov` + // sequence transforming the live order into the target order. + let target_ids: Vec = target.cells.iter().map(|cell| cell.id.to_string()).collect(); + apply_order_diff(&sheet_map, &target_ids).context("applying order-list moves")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + *sheet = target; + } + Ok(Some(Derived::Board)) + }, + FlowIntent::SetCellStruck { sheet_id, cell_id, struck } => { + let sheet = sheet_of(core, *sheet_id)?; + let cell = sheet.cell(*cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if cell.summary.struck == *struck { + return Ok(None); + } + let text = loro_schema::cell_text(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let len = text.len_unicode(); + if len <= 1 { + return Ok(None); + } + // A whole-text mark (expand-`After`), so concurrent typing merges UNDER + // the strike char-level instead of a record-blob LWW fight. + if *struck { + text + .mark(1..len, flowstate_document::MARK_STRIKETHROUGH, true) + .context("marking cell strikethrough")?; + } else { + text + .unmark(1..len, flowstate_document::MARK_STRIKETHROUGH) + .context("unmarking cell strikethrough")?; + } + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::EnsureCellEditable { sheet_id, cell_id } => { + let sheet = sheet_of(core, *sheet_id)?; + let cell = sheet.cell(*cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if cell.summary.uses_summary_projection { + return Ok(None); + } + let text = loro_schema::cell_text(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if text.len_unicode() == 0 { + return Err(FlowWriteRejected::StructureViolation("cell flow is unseeded".into()).into()); + } + // Restyle the FIRST paragraph (boundary 0) to the editable tag style. + text + .mark( + 0..1, + flowstate_document::MARK_PARAGRAPH_STYLE, + cell_text::paragraph_style_value(loro_schema::CELL_SEED_PARAGRAPH_STYLE), + ) + .context("restyling first paragraph to the tag style")?; + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::ReplaceCellContent { + sheet_id, + cell_id, + paragraphs, + } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + let cell_map = loro_schema::cell_map(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + loro_schema::cell_flow_from_paragraphs(&cell_map, paragraphs).context("replacing cell content")?; + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::AddAnnotation { sheet_id, stroke } => { + sheet_of(core, *sheet_id)?; + if stroke.sheet_id != *sheet_id { + return Err(FlowWriteRejected::StructureViolation("annotation sheet id mismatch".into()).into()); + } + loro_schema::put_annotation(doc, stroke).context("writing annotation stroke")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.annotations.retain(|existing| existing.id != stroke.id); + sheet.annotations.push(stroke.clone()); + sheet.annotations.sort_by_key(|stroke| stroke.id); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteAnnotation { + sheet_id, + stroke_id, + originator, + } => { + let sheet = sheet_of(core, *sheet_id)?; + let owned = sheet + .annotations + .iter() + .any(|stroke| stroke.id == *stroke_id && &stroke.originator == originator); + if !owned { + return Ok(None); + } + loro_schema::delete_annotation(doc, *stroke_id).context("deleting annotation stroke")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.annotations.retain(|stroke| stroke.id != *stroke_id); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::ClearAnnotations { sheet_id, originator } => { + if let Some(sheet_id) = sheet_id { + sheet_of(core, *sheet_id)?; + } + let doomed: Vec = core + .board_ref() + .sheets + .iter() + .filter(|sheet| sheet_id.is_none_or(|target| sheet.id == target)) + .flat_map(|sheet| &sheet.annotations) + .filter(|stroke| &stroke.originator == originator) + .map(|stroke| stroke.id) + .collect(); + if doomed.is_empty() { + return Ok(None); + } + for stroke in &doomed { + loro_schema::delete_annotation(doc, *stroke).context("clearing annotation stroke")?; + } + for sheet in &mut core.board_mut().sheets { + if sheet_id.is_none_or(|target| sheet.id == target) { + sheet.annotations.retain(|stroke| &stroke.originator != originator); + } + } + Ok(Some(Derived::Board)) + }, + FlowIntent::CellText { cell_id, intent } => { + let (owning_sheet, _) = core + .board_ref() + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let _ = owning_sheet; + let ctx = CellTextContext::resolve(doc, *cell_id)?; + // Identity basis: the OPEN cell's cached paragraph ids (the projection + // the intent's ids came from); fall back to a fresh materialization for + // a not-yet-open cell. + let paragraph_ids: Vec = match core.cells.get(cell_id) { + Some(entry) => entry.paragraph_ids.clone(), + None => flowstate_flow::loro_projection::materialize_cell_rows(doc, *cell_id) + .map(|rows| rows.paragraph_ids) + .map_err(|_| FlowWriteRejected::UnknownCell(*cell_id))?, + }; + let plan = cell_text::resolve_cell_plan(doc, &ctx, ¶graph_ids, intent)?; + let caret = cell_text::execute_cell_plan(&ctx, &plan).context("executing cell text plan")?; + Ok(Some(Derived::Content { + cell: *cell_id, + caret, + })) + }, + } +} + +/// Resolve a new cell's placement into `(column_index, flat_insertion_index, +/// parent)` against the maintained board — the former `add_*` quintet as one +/// law. +fn resolve_placement( + core: &FlowRuntime, + sheet_id: SheetId, + columns: &[Uuid], + placement: &CellPlacement, +) -> Result<(usize, usize, Option), FlowWriteRejected> { + let board = core.board_ref(); + let sheet = board.sheet(sheet_id).ok_or(FlowWriteRejected::UnknownSheet(sheet_id))?; + match placement { + CellPlacement::ColumnEnd { column_index } => { + if *column_index >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("column index out of range".into())); + } + Ok((*column_index, sheet.cells.len(), None)) + }, + CellPlacement::ColumnTop { column_index } => { + if *column_index >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("column index out of range".into())); + } + Ok((*column_index, 0, None)) + }, + CellPlacement::Sibling { of, position } => { + let index = sheet + .cells + .iter() + .position(|cell| cell.id == *of) + .ok_or(FlowWriteRejected::UnknownCell(*of))?; + let source = &sheet.cells[index]; + let column = columns + .iter() + .position(|column| *column == source.column_id) + .ok_or_else(|| FlowWriteRejected::StructureViolation("sibling references unknown column".into()))?; + let insertion = match position { + RelativePosition::Before => index, + // AFTER the sibling's WHOLE subtree: the canonical order keeps every + // subtree contiguous, so "after the sibling" can never mean "between + // the sibling and its responses". + RelativePosition::After => { + let subtree = board_ops::subtree_cell_ids(sheet, *of); + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| subtree.contains(&cell.id)) + .map(|(index, _)| index) + .max() + .unwrap_or(index) + + 1 + }, + }; + Ok((column, insertion, source.parent_id)) + }, + CellPlacement::FirstResponseTo { parent } | CellPlacement::ResponseTo { parent } => { + let parent_cell = sheet.cell(*parent).ok_or(FlowWriteRejected::UnknownCell(*parent))?; + let parent_column = columns + .iter() + .position(|column| *column == parent_cell.column_id) + .ok_or_else(|| FlowWriteRejected::StructureViolation("parent references unknown column".into()))?; + let child_column = parent_column + 1; + if child_column >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("rightmost cells cannot receive responses".into())); + } + let insertion = match placement { + CellPlacement::FirstResponseTo { .. } => board_ops::child_prepend_index(board, sheet_id, *parent), + _ => board_ops::child_append_index(board, sheet_id, *parent), + } + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}")))?; + Ok((child_column, insertion, Some(*parent))) + }, + } +} + +/// Transform the live cell order into `target` with a minimal `mov` sequence +/// (only displaced elements move; a subtree move emits one `mov` per member). +fn apply_order_diff(sheet_map: &loro::LoroMap, target: &[String]) -> Result<()> { + let mut working = loro_schema::cell_order_ids(sheet_map); + anyhow::ensure!( + working.len() == target.len(), + "order diff over mismatched sets ({} live vs {} target)", + working.len(), + target.len() + ); + for index in 0..target.len() { + if working[index] == target[index] { + continue; + } + let from = working + .iter() + .position(|id| *id == target[index]) + .context("target order entry missing from live order")?; + loro_schema::move_cell_order(sheet_map, from, index)?; + let id = working.remove(from); + working.insert(index, id); + } + Ok(()) +} + +/// Cursor-backed caret snapshot after a cell-text intent (spec §8 shape). +fn cell_selection_snapshot(doc: &LoroDoc, cell: CellId, caret_unicode: usize) -> Option { + let text = loro_schema::cell_text(doc, cell)?; + let cursor = cursor_for_boundary(&text, caret_unicode, crate::presence::SelectionAffinity::Neutral)?; + let ctx = CellTextContext::resolve(doc, cell).ok()?; + let offset = ctx + .offset_for_unicode(caret_unicode) + .unwrap_or(gpui_flowtext::DocumentOffset { paragraph: 0, byte: 0 }); + let endpoint = CursorEndpoint { + cursor: cursor.encode(), + delta: 0, + affinity: SelectionAffinity::Neutral, + gravity: VisualGravity::Neutral, + offset, + }; + Some(SelectionSnapshot { + anchor: endpoint.clone(), + head: endpoint, + }) +} + +/// I-10 recovery, verbatim law from the .db8 side: repair origin BEFORE +/// `revert_to` (its internal diff commits the partial), explicit repair commit +/// for the inverse ops, ONE publish payload covering partial + inverse, then a +/// defensive full refresh. +fn compensate_failed_intent( + core: &mut FlowRuntime, + doc: &LoroDoc, + frontier_before: &loro::Frontiers, + vv_before: &loro::VersionVector, + class: &'static str, + error: &anyhow::Error, +) -> FlowWriteRejected { + tracing::error!(class, %error, "flow intent failed mid-apply; compensating via revert_to (I-10)"); + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-intent-compensation"); + if let Err(revert_error) = doc.revert_to(frontier_before) { + tracing::error!(class, %revert_error, "revert_to during flow intent compensation FAILED; core must be reloaded"); + return FlowWriteRejected::CompensationFailed { + class, + diagnostic: format!("mid-apply error: {error:#}; revert_to error: {revert_error}"), + }; + } + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-intent-compensation-inverse"); + doc.commit(); + // One atomic publish payload covering partial + inverse (spec I-10c). + core.queue_local_update_publish(vv_before); + if let Err(refresh_error) = core.refresh_all() { + tracing::error!(class, %refresh_error, "flow refresh after compensation failed; core must be reloaded"); + return FlowWriteRejected::CompensationFailed { + class, + diagnostic: format!("mid-apply error: {error:#}; post-compensation refresh error: {refresh_error:#}"), + }; + } + FlowWriteRejected::CompensatedFailure { + class, + diagnostic: format!("{error:#}"), + } +} diff --git a/crates/flowstate-collab/src/flow/flow_io.rs b/crates/flowstate-collab/src/flow/flow_io.rs new file mode 100644 index 00000000..b25067c7 --- /dev/null +++ b/crates/flowstate-collab/src/flow/flow_io.rs @@ -0,0 +1,281 @@ +//! `FlowIoService` — the background flow I/O service, sibling of +//! `DocIoService` (Loro-first spec §3/§6, applied to the flow gate). +//! +//! Shares the `WriteGate` with the UI thread's `FlowDocHandle` +//! and acquires it per doc-touching operation. Duties: remote import chunks +//! (coalesced under one gate hold with the §6.4 byte budget), publish-queue +//! drains, update/snapshot exports (fork under the gate, export off it), and +//! `.fl0` persistence (atomic save + recovery-file bytes). + +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context as _, Result}; +use async_channel::{Receiver, Sender}; +use flowstate_flow::projection::FlowBoardProjection; +use loro::{ExportMode, VersionVector}; + +use super::runtime::{FlowPublishEvent, FlowRuntime}; +use crate::io_util::{gate_call, send_reply}; +use crate::local_write::{GateHolder, WriteGate}; + +const IO_REQUEST_CHANNEL_CAPACITY: usize = 256; +/// Maximum queued import blobs coalesced into one gate hold (spec §6.4). +const IMPORT_COALESCE_MAX: usize = 16; +/// §A14.1.1 byte budget: never glue chunked mass-op slices back into one hold. +const IMPORT_COALESCE_BYTE_BUDGET: usize = 64 * 1024; + +type ReplySender = Sender>; + +pub enum FlowIoRequest { + ImportRemoteUpdate { + bytes: Vec, + reply: ReplySender<()>, + }, + PumpPublish { + reply: ReplySender>, + }, + BoardSnapshot { + reply: ReplySender, + }, + OplogVersionVector { + reply: ReplySender>, + }, + ExportUpdatesFor { + remote_vv: Vec, + reply: ReplySender>, + }, + SnapshotBytes { + reply: ReplySender>, + }, + /// Atomic `.fl0` save (encode v2 + write-then-rename). + SaveTo { + path: PathBuf, + reply: ReplySender<()>, + }, + /// Encoded `.fl0` bytes (recovery files for pathless joined tabs). + EncodeBytes { + reply: ReplySender>, + }, +} + +fn request_kind(request: &FlowIoRequest) -> &'static str { + match request { + FlowIoRequest::ImportRemoteUpdate { .. } => "import-remote-update", + FlowIoRequest::PumpPublish { .. } => "pump-publish", + FlowIoRequest::BoardSnapshot { .. } => "board-snapshot", + FlowIoRequest::OplogVersionVector { .. } => "oplog-version-vector", + FlowIoRequest::ExportUpdatesFor { .. } => "export-updates-for", + FlowIoRequest::SnapshotBytes { .. } => "snapshot-bytes", + FlowIoRequest::SaveTo { .. } => "save-to", + FlowIoRequest::EncodeBytes { .. } => "encode-bytes", + } +} + +/// Marker type for the service thread (spawned via [`FlowIoHandle::spawn`]). +pub struct FlowIoService; + +/// Cloneable handle to the flow I/O service thread. +#[derive(Clone)] +pub struct FlowIoHandle { + requests: Sender, +} + +impl FlowIoHandle { + pub fn spawn(core: Arc>) -> io::Result { + let (sender, receiver) = async_channel::bounded(IO_REQUEST_CHANNEL_CAPACITY); + std::thread::Builder::new() + .name("flowstate-flow-io".to_string()) + .spawn(move || io_loop(&core, &receiver))?; + Ok(Self { requests: sender }) + } + + async fn request(&self, build: impl FnOnce(ReplySender) -> FlowIoRequest) -> Result { + let (reply, response) = async_channel::bounded(1); + self + .requests + .send(build(reply)) + .await + .map_err(|_| anyhow::anyhow!("flow I/O service stopped"))?; + response + .recv() + .await + .map_err(|_| anyhow::anyhow!("flow I/O service dropped the reply"))? + } + + pub async fn import_remote_update(&self, bytes: Vec) -> Result<()> { + self + .request(|reply| FlowIoRequest::ImportRemoteUpdate { bytes, reply }) + .await + } + + /// Drain committed-but-unpublished local events. + pub async fn pump_publish(&self) -> Result> { + self.request(|reply| FlowIoRequest::PumpPublish { reply }).await + } + + pub async fn board_snapshot(&self) -> Result { + self.request(|reply| FlowIoRequest::BoardSnapshot { reply }).await + } + + pub async fn oplog_version_vector(&self) -> Result> { + self + .request(|reply| FlowIoRequest::OplogVersionVector { reply }) + .await + } + + pub async fn export_updates_for(&self, remote_vv: Vec) -> Result> { + self + .request(|reply| FlowIoRequest::ExportUpdatesFor { remote_vv, reply }) + .await + } + + pub async fn snapshot_bytes(&self) -> Result> { + self.request(|reply| FlowIoRequest::SnapshotBytes { reply }).await + } + + pub async fn save_to(&self, path: PathBuf) -> Result<()> { + self.request(|reply| FlowIoRequest::SaveTo { path, reply }).await + } + + pub async fn encode_bytes(&self) -> Result> { + self.request(|reply| FlowIoRequest::EncodeBytes { reply }).await + } +} + +fn io_loop(core: &Arc>, receiver: &Receiver) { + let mut deferred: Vec = Vec::new(); + loop { + let request = if let Some(request) = deferred.pop() { + request + } else { + match receiver.recv_blocking() { + Ok(request) => request, + Err(_) => break, + } + }; + let kind = request_kind(&request); + let started = std::time::Instant::now(); + match request { + FlowIoRequest::ImportRemoteUpdate { bytes, reply } => { + // Spec §6.4: coalesce immediately-available import blobs into ONE gate + // acquisition, respecting the byte budget; non-import requests popped + // while draining are deferred, order preserved among themselves. + let mut coalesced_bytes = bytes.len(); + let mut chunk: Vec<(Vec, ReplySender<()>)> = vec![(bytes, reply)]; + while chunk.len() < IMPORT_COALESCE_MAX && coalesced_bytes < IMPORT_COALESCE_BYTE_BUDGET { + match receiver.try_recv() { + Ok(FlowIoRequest::ImportRemoteUpdate { bytes, reply }) => { + coalesced_bytes += bytes.len(); + chunk.push((bytes, reply)); + }, + Ok(other) => { + deferred.push(other); + break; + }, + Err(_) => break, + } + } + match core.lock(GateHolder::ImportChunk) { + Ok(mut guard) => { + // §act-five P1.A second drain: fold in blobs that arrived while + // this thread was blocked on the gate — no added latency. + while chunk.len() < IMPORT_COALESCE_MAX && coalesced_bytes < IMPORT_COALESCE_BYTE_BUDGET { + match receiver.try_recv() { + Ok(FlowIoRequest::ImportRemoteUpdate { bytes, reply }) => { + coalesced_bytes += bytes.len(); + chunk.push((bytes, reply)); + }, + Ok(other) => { + deferred.push(other); + break; + }, + Err(_) => break, + } + } + let blobs: Vec<&[u8]> = chunk.iter().map(|(bytes, _)| bytes.as_slice()).collect(); + match guard.import_remote_updates(&blobs) { + Ok(()) => { + for (_, reply) in &chunk { + send_reply(reply, Ok(())); + } + }, + Err(error) => { + let error = format!("{error:#}"); + for (_, reply) in &chunk { + send_reply(reply, Err(anyhow::anyhow!("batched flow import failed: {error}"))); + } + }, + } + if chunk.len() > 1 { + tracing::debug!(coalesced = chunk.len(), "coalesced flow import chunk under one gate hold"); + } + }, + Err(poisoned) => { + for (_, reply) in chunk { + send_reply(&reply, Err(anyhow::anyhow!(poisoned))); + } + }, + } + }, + FlowIoRequest::PumpPublish { reply } => { + let result = core + .lock(GateHolder::ExportUpdates) + .map(|mut guard| guard.take_pending_publish()) + .map_err(|poisoned| anyhow::anyhow!(poisoned)); + send_reply(&reply, result); + }, + FlowIoRequest::BoardSnapshot { reply } => send_reply( + &reply, + gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.board_ref().clone())), + ), + FlowIoRequest::OplogVersionVector { reply } => send_reply( + &reply, + gate_call(core, GateHolder::ExportUpdates, |runtime| { + Ok(runtime.oplog_version_vector().encode()) + }), + ), + FlowIoRequest::ExportUpdatesFor { remote_vv, reply } => { + let result = VersionVector::decode(&remote_vv) + .context("decoding remote Loro version vector") + .and_then(|vv| gate_call(core, GateHolder::ExportUpdates, |runtime| runtime.export_updates_for(&vv))); + send_reply(&reply, result); + }, + FlowIoRequest::SnapshotBytes { reply } => { + // I-9a long-export rule: fork under the gate (brief), export off it. + let forked = gate_call(core, GateHolder::ExportFork, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot from fork") + }); + send_reply(&reply, result); + }, + FlowIoRequest::SaveTo { path, reply } => { + let forked = gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + let snapshot = fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot for save")?; + flowstate_flow::write_fl0(&path, &snapshot) + }); + send_reply(&reply, result); + }, + FlowIoRequest::EncodeBytes { reply } => { + let forked = gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + let snapshot = fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot for recovery bytes")?; + Ok(flowstate_flow::encode_fl0_snapshot(&snapshot)) + }); + send_reply(&reply, result); + }, + } + let elapsed = started.elapsed(); + if elapsed > std::time::Duration::from_millis(250) { + tracing::debug!(kind, ?elapsed, "slow flow I/O request"); + } + } +} diff --git a/crates/flowstate-collab/src/flow/handle.rs b/crates/flowstate-collab/src/flow/handle.rs new file mode 100644 index 00000000..cf3773bd --- /dev/null +++ b/crates/flowstate-collab/src/flow/handle.rs @@ -0,0 +1,176 @@ +//! `FlowDocHandle` — the ONE local write API for a flow document (invariant 5: +//! solo and collaborative tabs hold this identical handle; collaboration only +//! adds the flow I/O service draining the publish queue behind the same gate). + +use std::sync::{Arc, Mutex}; + +use flowstate_flow::format::{CellId, FlowFormat}; +use flowstate_flow::intents::FlowIntent; +use flowstate_flow::projection::FlowBoardProjection; +use gpui_flowtext::DocumentProjection; +use uuid::Uuid; + +use super::cell_authority::FlowCellAuthority; +use super::commit::apply_flow_intent; +use super::runtime::{FlowRuntime, FlowStreamItem, FlowUndoMeta, FlowUndoOutcome, FlowWriteOutcome, FlowWriteRejected}; +use crate::local_write::{GateHolder, WriteGate}; + +pub struct FlowDocHandle { + core: Arc>, + /// Board context stamped onto each committed intent's undo item — kept + /// current by the editor (focus/sheet changes), read by `apply`. + undo_context: Mutex, +} + +impl FlowDocHandle { + /// Wrap a runtime. The returned gate is shared with the flow I/O service so + /// imports/exports serialize with local writes. + #[must_use] + pub fn new(core: FlowRuntime) -> (Arc, Arc>) { + let gate = Arc::new(WriteGate::new(core)); + (Self::attach(Arc::clone(&gate)), gate) + } + + /// Attach to an existing shared core (join handoff). + #[must_use] + pub fn attach(core: Arc>) -> Arc { + Arc::new(Self { + core, + undo_context: Mutex::new(FlowUndoMeta::default()), + }) + } + + /// Fresh solo document. + pub fn new_document(format: &FlowFormat) -> anyhow::Result<(Arc, Arc>)> { + Ok(Self::new(FlowRuntime::new(format)?)) + } + + #[must_use] + pub fn gate(&self) -> Arc> { + Arc::clone(&self.core) + } + + fn lock(&self, holder: GateHolder) -> Result, FlowWriteRejected> { + self.core.lock(holder).map_err(|_| FlowWriteRejected::GatePoisoned) + } + + /// Update the board context riding future undo items (focus restoration). + pub fn set_undo_context(&self, meta: FlowUndoMeta) { + if let Ok(mut context) = self.undo_context.lock() { + *context = meta; + } + } + + pub(crate) fn undo_context(&self) -> FlowUndoMeta { + self + .undo_context + .lock() + .map(|context| context.clone()) + .unwrap_or_default() + } + + /// Apply one flow intent: the whole resolve → mutate → commit → derive path + /// under one gate hold; the returned outcome IS the committed state. + pub fn apply(&self, intent: FlowIntent) -> Result { + let meta = self.undo_context(); + let mut guard = self.lock(GateHolder::LocalIntent)?; + guard.set_pending_undo_meta(Some(meta)); + let outcome = apply_flow_intent(&mut guard, &intent); + guard.set_pending_undo_meta(None); + outcome + } + + pub fn document_id(&self) -> Result { + Ok(self.lock(GateHolder::LocalIntent)?.document_id()) + } + + /// Clone the canonical board (editor attach; afterwards the editor advances + /// its copy exclusively via the ordered board stream). + pub fn board_projection(&self) -> Result { + Ok(self.lock(GateHolder::LocalIntent)?.board_ref().clone()) + } + + pub fn drain_board_stream(&self) -> Result, FlowWriteRejected> { + Ok(self.lock(GateHolder::LocalIntent)?.take_board_stream()) + } + + /// Materialize + start streaming one cell (editor attach path). + pub fn open_cell(&self, cell: CellId) -> Result { + self + .lock(GateHolder::LocalIntent)? + .open_cell(cell) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + } + + pub fn close_cell(&self, cell: CellId) -> Result<(), FlowWriteRejected> { + self.lock(GateHolder::LocalIntent)?.close_cell(cell); + Ok(()) + } + + /// The per-cell write authority injected into that cell's `RichTextEditor` + /// (`RichTextEditor::set_write_authority`). + #[must_use] + pub fn cell_authority(self: &Arc, cell: CellId) -> Arc { + Arc::new(FlowCellAuthority::new(Arc::clone(self), cell)) + } + + // ---- Undo (spec §10) ------------------------------------------------------- + + pub fn undo(&self) -> Result { + self + .lock(GateHolder::UndoRedo)? + .undo() + .map_err(|error| FlowWriteRejected::CompensatedFailure { + class: "flow-undo", + diagnostic: format!("{error:#}"), + }) + } + + pub fn redo(&self) -> Result { + self + .lock(GateHolder::UndoRedo)? + .redo() + .map_err(|error| FlowWriteRejected::CompensatedFailure { + class: "flow-redo", + diagnostic: format!("{error:#}"), + }) + } + + pub fn undo_group_start(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.undo_group_start()) + } + + pub fn undo_group_end(&self) -> Result<(), FlowWriteRejected> { + self.lock(GateHolder::UndoRedo)?.undo_group_end(); + Ok(()) + } + + pub fn can_undo(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.can_undo()) + } + + pub fn can_redo(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.can_redo()) + } + + /// Test/tooling support: run a closure against the gate-held runtime (the + /// flow analog of holding a test gate guard on the .db8 core). + pub fn with_test_runtime(&self, call: impl FnOnce(&mut FlowRuntime) -> T) -> T { + let mut guard = self + .core + .lock(GateHolder::DocumentService) + .expect("flow write gate healthy"); + call(&mut guard) + } + + // ---- Gate-held reads shared with the cell authority ------------------------ + + pub(crate) fn with_runtime( + &self, + holder: GateHolder, + read: impl FnOnce(&mut FlowRuntime) -> T, + ) -> Result { + let mut guard = self.lock(holder)?; + Ok(read(&mut guard)) + } +} diff --git a/crates/flowstate-collab/src/flow/mod.rs b/crates/flowstate-collab/src/flow/mod.rs new file mode 100644 index 00000000..cc19948e --- /dev/null +++ b/crates/flowstate-collab/src/flow/mod.rs @@ -0,0 +1,30 @@ +//! The .fl0 CRDT-first write path (flow spec Part B) — the flow mirror of the +//! .db8 `local_write`/`crdt_runtime` split, at board scale. +//! +//! One law, reused: every local mutation is a typed [`flowstate_flow::FlowIntent`] +//! applied through [`FlowDocHandle`] → `WriteGate` (the SAME +//! generic gate the .db8 core uses) → resolve → mutate → ONE commit (origin +//! `"local"`, message = intent class) → in-place projection derivation → the +//! ordered streams → the publish queue. Remote traffic reaches the same doc +//! only through the flow I/O service, one import chunk per gate hold. +//! +//! Streams (single-ordered-stream law per consumer): +//! * ONE board stream (`FlowStreamItem::Board`, Replace-per-change — board +//! metadata is ~100 bytes/cell with shared `Arc` summaries), drained by +//! the `FlowEditor`. +//! * ONE stream per OPEN cell (`ProjectionStreamItem`, whole-cell `Replace` — +//! cells are tiny, the .db8 body patch synthesis is deliberately not +//! ported), drained by that cell's `RichTextEditor` via its +//! [`cell_authority::FlowCellAuthority`]. + +pub mod cell_authority; +pub mod cell_text; +pub mod commit; +pub mod flow_io; +pub mod handle; +pub mod runtime; + +pub use cell_authority::FlowCellAuthority; +pub use flow_io::{FlowIoHandle, FlowIoService}; +pub use handle::FlowDocHandle; +pub use runtime::{FlowPublishEvent, FlowRuntime, FlowStreamItem, FlowUndoMeta, FlowUndoOutcome, FlowWriteOutcome, FlowWriteRejected}; diff --git a/crates/flowstate-collab/src/flow/runtime.rs b/crates/flowstate-collab/src/flow/runtime.rs new file mode 100644 index 00000000..3b7ec5c0 --- /dev/null +++ b/crates/flowstate-collab/src/flow/runtime.rs @@ -0,0 +1,552 @@ +//! `FlowRuntime` — the gate-protected flow document core (flow spec Part B). +//! +//! Owns the `LoroDoc`, the maintained [`FlowBoardProjection`], the per-open-cell +//! materialization cache, the ordered board/cell streams, the publish queue, +//! and the Loro `UndoManager` (origins `"remote"`/`"repair"` excluded, `"meta"` +//! inert — the .db8 configuration verbatim). Every method assumes the caller +//! holds the `WriteGate`. + +use std::sync::{Arc, Mutex}; + +use anyhow::{Context as _, Result}; +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::loro_projection::{materialize_board, materialize_cell_rows, summary_from_rows}; +use flowstate_flow::projection::{CellSummary, FlowBoardProjection}; +use flowstate_flow::{FlowDefect, loro_schema}; +use gpui_flowtext::{DocumentProjection, InputBlock, ParagraphId, ProjectionStreamItem}; +use loro::{ExportMode, InnerUndoManager as UndoManager, LoroDoc, LoroValue, UndoItemMeta, VersionVector, cursor::Cursor}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// One item of the ordered BOARD stream: Replace-per-change (board metadata is +/// small; summaries are shared `Arc`s). +#[derive(Clone, Debug)] +pub enum FlowStreamItem { + Board(Box), +} + +/// Publish-side events buffered under the gate for the flow I/O service (the +/// flow mirror of the .db8 `RuntimeEvent` publish subset). +#[derive(Clone, Debug)] +pub enum FlowPublishEvent { + LocalUpdate { + bytes: Vec, + frontier: Vec, + version_vector: Vec, + }, + RemoteUpdateApplied { + frontier: Vec, + version_vector: Vec, + }, +} + +/// Board-context metadata riding each undo item (`FlowUndoMeta`, spec Part B): +/// restores sheet focus, cell focus, and the cell-text selection through the +/// undo outcome. Cursors are Loro cursor bytes, actively transformed by the +/// `UndoManager` while the item sits on the stack. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct FlowUndoMeta { + pub active_sheet: Option, + pub focused_cell: Option, + pub head_cursor: Option>, + pub anchor_cursor: Option>, +} + +/// Outcome of an undo/redo (the projection change rides the ordered streams). +#[derive(Clone, Debug, Default)] +pub struct FlowUndoOutcome { + pub applied: bool, + pub meta: Option, +} + +/// The synchronous result of a committed flow intent. +#[derive(Clone, Debug)] +pub struct FlowWriteOutcome { + /// `false` when the intent resolved to a no-op (nothing committed). + pub changed: bool, + pub frontier: Vec, + pub version_vector: Vec, + /// Cursor-backed caret after a `CellText` intent. + pub selection_after: Option, +} + +/// Why a flow intent was rejected before mutation (I-15: rejection means the +/// doc was not touched; compensated failures mean it was restored). +#[derive(Clone, Debug)] +pub enum FlowWriteRejected { + UnknownSheet(SheetId), + UnknownCell(CellId), + UnresolvedParagraph(ParagraphId), + UnresolvedCursor, + EmptyIntent, + StructureViolation(String), + GatePoisoned, + CompensatedFailure { class: &'static str, diagnostic: String }, + CompensationFailed { class: &'static str, diagnostic: String }, +} + +impl std::fmt::Display for FlowWriteRejected { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownSheet(sheet) => write!(f, "sheet {sheet} did not resolve against canonical flow state"), + Self::UnknownCell(cell) => write!(f, "cell {cell} did not resolve against canonical flow state"), + Self::UnresolvedParagraph(id) => write!(f, "cell paragraph identity {} did not resolve", id.0), + Self::UnresolvedCursor => f.write_str("supplied cursor could not be resolved against the cell's text"), + Self::EmptyIntent => f.write_str("flow intent is a no-op"), + Self::StructureViolation(reason) => write!(f, "flow intent violates board structure: {reason}"), + Self::GatePoisoned => f.write_str("flow write gate poisoned; reload from persisted state"), + Self::CompensatedFailure { class, diagnostic } => { + write!(f, "flow intent '{class}' failed mid-apply and was compensated back: {diagnostic}") + }, + Self::CompensationFailed { class, diagnostic } => { + write!(f, "flow intent '{class}' failed mid-apply AND compensation failed: {diagnostic}") + }, + } + } +} + +impl std::error::Error for FlowWriteRejected {} + +/// Cached materialization for one OPEN cell (an attached editor): the change +/// detector for import-driven refreshes and the resolution basis for text +/// anchors. +pub(crate) struct CellEntry { + pub(crate) blocks: Vec, + pub(crate) paragraph_ids: Vec, +} + +#[derive(Default)] +pub(crate) struct FlowUndoMetaState { + /// Set by the handle before each intent; captured by `on_push`. + pub(crate) pending: Option, + /// Set by `on_pop` when an undo/redo restores an item's metadata. + pub(crate) restored: Option, +} + +pub struct FlowRuntime { + doc: LoroDoc, + board: FlowBoardProjection, + document_id: Uuid, + pub(crate) cells: FxHashMap, + board_stream: Vec, + cell_streams: FxHashMap>, + pending_publish: Vec, + pub(crate) undo: UndoManager, + undo_meta: Arc>, + /// Latest normalization defects (repair-pass input; capped by stable key). + pub(crate) defects: Vec, +} + +impl FlowRuntime { + /// Fresh flow document (solo new-tab path). + pub fn new(format: &FlowFormat) -> Result { + let doc = LoroDoc::new(); + let document_id = loro_schema::init_flow_document(&doc, format)?; + doc.set_next_commit_origin("meta"); + doc.set_next_commit_message("flow-init"); + doc.commit(); + Self::from_doc(doc, document_id) + } + + /// Attach to an imported snapshot (open / join). Validates the v2 schema — + /// the wrong-kind-session and wrong-schema defense. + pub fn from_snapshot(snapshot: &[u8]) -> Result { + let doc = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&doc); + doc.import(snapshot).context("importing flow snapshot")?; + let version = loro_schema::read_schema_version(&doc); + anyhow::ensure!( + version == Some(loro_schema::FLOW_SCHEMA_VERSION), + "flow snapshot has schema version {version:?}, expected {} — not a Flowstate .fl0 v2 document", + loro_schema::FLOW_SCHEMA_VERSION + ); + let document_id = loro_schema::read_document_id(&doc).context("flow snapshot has no document id")?; + Self::from_doc(doc, document_id) + } + + fn from_doc(doc: LoroDoc, document_id: Uuid) -> Result { + let (board, defects) = materialize_board(&doc)?; + let undo = UndoManager::new(doc.inner()); + // Spec §10 (D6) verbatim: undo units are explicit group boundaries. + undo.set_merge_interval(0); + undo.set_max_undo_steps(300); + undo.add_exclude_origin_prefix("remote"); + undo.add_exclude_origin_prefix("repair"); + undo.add_inert_origin_prefix("meta"); + let undo_meta = Arc::new(Mutex::new(FlowUndoMetaState::default())); + install_undo_meta_callbacks(&undo, &undo_meta); + Ok(Self { + doc, + board, + document_id, + cells: FxHashMap::default(), + board_stream: Vec::new(), + cell_streams: FxHashMap::default(), + pending_publish: Vec::new(), + undo, + undo_meta, + defects, + }) + } + + pub fn doc(&self) -> &LoroDoc { + &self.doc + } + + #[must_use] + pub fn document_id(&self) -> Uuid { + self.document_id + } + + #[must_use] + pub fn board_ref(&self) -> &FlowBoardProjection { + &self.board + } + + pub(crate) fn board_mut(&mut self) -> &mut FlowBoardProjection { + &mut self.board + } + + pub fn frontier(&self) -> Vec { + self.doc.state_frontiers().encode() + } + + pub fn oplog_version_vector(&self) -> VersionVector { + self.doc.oplog_vv() + } + + pub fn export_updates_for(&self, version: &VersionVector) -> Result> { + Ok(self.doc.export(ExportMode::updates(version))?) + } + + pub fn snapshot(&self) -> Result> { + Ok(self.doc.export(ExportMode::Snapshot)?) + } + + // ---- Streams + publish queue --------------------------------------------- + + pub(crate) fn push_board_stream(&mut self) { + self + .board_stream + .push(FlowStreamItem::Board(Box::new(self.board.clone()))); + } + + pub fn take_board_stream(&mut self) -> Vec { + std::mem::take(&mut self.board_stream) + } + + pub fn take_cell_stream(&mut self, cell: CellId) -> Vec { + self + .cell_streams + .get_mut(&cell) + .map(std::mem::take) + .unwrap_or_default() + } + + pub fn queue_publish(&mut self, events: Vec) { + self.pending_publish.extend(events); + } + + pub fn take_pending_publish(&mut self) -> Vec { + std::mem::take(&mut self.pending_publish) + } + + pub fn has_pending_publish(&self) -> bool { + !self.pending_publish.is_empty() + } + + /// Export the covering update since `vv_before` and queue it for publish. + pub(crate) fn queue_local_update_publish(&mut self, vv_before: &VersionVector) { + match self.doc.export(ExportMode::updates(vv_before)) { + Ok(bytes) if !bytes.is_empty() => { + let event = FlowPublishEvent::LocalUpdate { + bytes, + frontier: self.frontier(), + version_vector: self.doc.state_vv().encode(), + }; + self.pending_publish.push(event); + }, + Ok(_) => {}, + Err(error) => { + // Anti-entropy recovers the update; the committed intent stands. + tracing::error!(%error, "exporting committed flow update failed; anti-entropy must recover it"); + }, + } + } + + // ---- Open-cell lifecycle --------------------------------------------------- + + /// Materialize (and start streaming) one cell's rich-text projection — the + /// editor-attach path. Idempotent per cell. + pub fn open_cell(&mut self, cell: CellId) -> Result { + let rows = materialize_cell_rows(&self.doc, cell)?; + let projection = flowstate_flow::loro_projection::document_from_rows( + &self.doc, + flowstate_document::RegionRows { + blocks: rows.blocks.clone(), + paragraph_ids: rows.paragraph_ids.clone(), + block_ids: rows.block_ids.clone(), + defects: Vec::new(), + }, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ); + self.cells.insert( + cell, + CellEntry { + blocks: rows.blocks, + paragraph_ids: rows.paragraph_ids, + }, + ); + self.cell_streams.entry(cell).or_default(); + Ok(projection) + } + + /// Drop the cell's stream + cache (editor detached). + pub fn close_cell(&mut self, cell: CellId) { + self.cells.remove(&cell); + self.cell_streams.remove(&cell); + } + + #[must_use] + pub fn is_cell_open(&self, cell: CellId) -> bool { + self.cells.contains_key(&cell) + } + + /// Rematerialize one OPEN cell; on change, update the cache, push a whole- + /// cell `Replace` onto its stream, and refresh its board summary in place. + /// Returns whether the cell's SUMMARY changed (the board-stream trigger). + pub(crate) fn refresh_cell(&mut self, cell: CellId) -> Result { + let Some(entry) = self.cells.get(&cell) else { + // Not open: only the summary can be stale. + return self.refresh_board_summary(cell); + }; + let rows = materialize_cell_rows(&self.doc, cell)?; + if rows.blocks == entry.blocks && rows.paragraph_ids == entry.paragraph_ids { + return Ok(false); + } + let summary_changed = self.set_board_summary(cell, summary_from_rows(&rows.blocks)); + let projection = flowstate_flow::loro_projection::document_from_rows( + &self.doc, + flowstate_document::RegionRows { + blocks: rows.blocks.clone(), + paragraph_ids: rows.paragraph_ids.clone(), + block_ids: rows.block_ids.clone(), + defects: Vec::new(), + }, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ); + self.cells.insert( + cell, + CellEntry { + blocks: rows.blocks, + paragraph_ids: rows.paragraph_ids, + }, + ); + self + .cell_streams + .entry(cell) + .or_default() + .push(ProjectionStreamItem::Replace(Box::new(projection))); + Ok(summary_changed) + } + + /// Refresh only the board-side summary of a (possibly closed) cell. + pub(crate) fn refresh_board_summary(&mut self, cell: CellId) -> Result { + let rows = materialize_cell_rows(&self.doc, cell)?; + Ok(self.set_board_summary(cell, summary_from_rows(&rows.blocks))) + } + + fn set_board_summary(&mut self, cell: CellId, summary: CellSummary) -> bool { + for sheet in &mut self.board.sheets { + if let Some(found) = sheet.cells.iter_mut().find(|candidate| candidate.id == cell) { + if found.summary == summary { + return false; + } + found.summary = summary; + return true; + } + } + false + } + + // ---- Whole-board refresh (imports, undo, compensation) -------------------- + + /// Rebuild the board from canonical state, refresh every open cell, and push + /// Replace items on every affected stream. The import/undo derivation path. + pub(crate) fn refresh_all(&mut self) -> Result<()> { + let (board, defects) = materialize_board(&self.doc)?; + self.defects = defects; + self.board = board; + self.push_board_stream(); + let open: Vec = self.cells.keys().copied().collect(); + for cell in open { + let live = self.board.cell(cell).is_some(); + if live { + // `refresh_cell` re-derives the summary; the fresh board already has + // it, so only the cell stream side effect matters here. + let _ = self.refresh_cell(cell)?; + } else { + // Cell deleted remotely: the board Replace already tells the editor; + // drop the dead stream + cache. + self.close_cell(cell); + } + } + Ok(()) + } + + /// Debug audit (spec §7 shape): the in-place board maintained by the intent + /// executors must equal an independent fresh materialization. + #[cfg(debug_assertions)] + pub(crate) fn audit_board_against_rebuild(&self, class: &str) { + match materialize_board(&self.doc) { + Ok((fresh, _)) => { + assert!( + fresh == self.board, + "flow intent '{class}' left the maintained board out of sync with a fresh materialization" + ); + }, + Err(error) => panic!("flow board audit rematerialization failed after '{class}': {error:#}"), + } + } + + // ---- Remote import --------------------------------------------------------- + + /// Import remote update chunks under ONE gate hold with ONE derivation. + pub fn import_remote_updates(&mut self, chunks: &[&[u8]]) -> Result<()> { + if chunks.is_empty() { + return Ok(()); + } + let frontier_before = self.doc.state_frontiers(); + for chunk in chunks { + self.doc.import(chunk).context("importing remote flow update")?; + } + if self.doc.state_frontiers() == frontier_before { + return Ok(()); + } + self.refresh_all()?; + let event = FlowPublishEvent::RemoteUpdateApplied { + frontier: self.frontier(), + version_vector: self.doc.state_vv().encode(), + }; + self.pending_publish.push(event); + Ok(()) + } + + // ---- Undo / redo ----------------------------------------------------------- + + pub(crate) fn set_pending_undo_meta(&mut self, meta: Option) { + if let Ok(mut state) = self.undo_meta.lock() { + state.pending = meta; + } + } + + pub fn undo(&mut self) -> Result { + self.undo_redo_step(true) + } + + pub fn redo(&mut self) -> Result { + self.undo_redo_step(false) + } + + fn undo_redo_step(&mut self, undo: bool) -> Result { + let vv_before = self.doc.oplog_vv(); + let applied = if undo { + self.undo.undo().context("applying flow undo")? + } else { + self.undo.redo().context("applying flow redo")? + }; + if !applied { + return Ok(FlowUndoOutcome::default()); + } + self.refresh_all()?; + self.queue_local_update_publish(&vv_before); + let meta = self + .undo_meta + .lock() + .ok() + .and_then(|mut state| state.restored.take()); + Ok(FlowUndoOutcome { applied: true, meta }) + } + + pub fn undo_group_start(&mut self) -> bool { + match self.undo.group_start() { + Ok(()) => true, + Err(error) => { + tracing::debug!(%error, "flow undo group_start declined; re-arming at next boundary"); + false + }, + } + } + + pub fn undo_group_end(&mut self) { + self.undo.group_end(); + } + + #[must_use] + pub fn can_undo(&self) -> bool { + self.undo.can_undo() + } + + #[must_use] + pub fn can_redo(&self) -> bool { + self.undo.can_redo() + } +} + +fn install_undo_meta_callbacks(undo: &UndoManager, state: &Arc>) { + let push_state = Arc::clone(state); + undo.set_on_push(Some(Box::new(move |_, _, _| { + let mut meta = UndoItemMeta::new(); + if let Ok(state) = push_state.lock() + && let Some(pending) = &state.pending + { + // Cursors ride the first-class `cursors` field so Loro transforms them + // through remote diffs while the item sits on the stack (spec §10). + for encoded in [pending.head_cursor.as_deref(), pending.anchor_cursor.as_deref()] + .into_iter() + .flatten() + { + if let Ok(cursor) = Cursor::decode(encoded) { + meta.add_cursor(&cursor); + } + } + if let Ok(bytes) = postcard::to_allocvec(pending) { + meta.set_value(LoroValue::Binary(bytes.into())); + } + } + meta + }))); + + let pop_state = Arc::clone(state); + undo.set_on_pop(Some(Box::new(move |_, _, meta| { + let transformed: Vec> = meta + .cursors + .iter() + .map(|entry| entry.cursor.encode()) + .collect(); + let LoroValue::Binary(bytes) = meta.value else { + return; + }; + match postcard::from_bytes::(bytes.as_ref()) { + Ok(mut restored) => { + // Prefer the stack-transformed cursors over the capture-time bytes. + let had_head = restored.head_cursor.is_some(); + let had_anchor = restored.anchor_cursor.is_some(); + let mut transformed = transformed.into_iter(); + if had_head && let Some(head) = transformed.next() { + restored.head_cursor = Some(head); + } + if had_anchor && let Some(anchor) = transformed.next() { + restored.anchor_cursor = Some(anchor); + } + if let Ok(mut state) = pop_state.lock() { + state.restored = Some(restored); + } + }, + Err(error) => { + tracing::warn!(%error, "decoding flow undo metadata failed"); + }, + } + }))); +} diff --git a/crates/flowstate-collab/src/io_util.rs b/crates/flowstate-collab/src/io_util.rs new file mode 100644 index 00000000..2f4979a5 --- /dev/null +++ b/crates/flowstate-collab/src/io_util.rs @@ -0,0 +1,22 @@ +//! Tiny helpers shared by the per-kind I/O services (`doc_io` keeps its own +//! battle-tested copies untouched; `flow/flow_io` uses these). + +use std::sync::Arc; + +use anyhow::Result; +use async_channel::Sender; + +use crate::local_write::{GateHolder, WriteGate}; + +pub(crate) fn gate_call(core: &Arc>, holder: GateHolder, call: impl FnOnce(&mut C) -> Result) -> Result { + let mut guard = core + .lock(holder) + .map_err(|poisoned| anyhow::anyhow!(poisoned))?; + call(&mut guard) +} + +pub(crate) fn send_reply(reply: &Sender>, value: Result) { + if let Err(error) = reply.try_send(value) { + tracing::warn!(%error, "I/O reply channel dropped before the reply was sent"); + } +} diff --git a/crates/flowstate-collab/src/lib.rs b/crates/flowstate-collab/src/lib.rs index fefaca9b..2f9d9f05 100644 --- a/crates/flowstate-collab/src/lib.rs +++ b/crates/flowstate-collab/src/lib.rs @@ -10,8 +10,10 @@ pub mod crdt_runtime; pub mod discovery; pub mod doc_io; pub mod dropbox; +pub mod flow; pub mod identity; pub mod ids; +pub(crate) mod io_util; pub mod local_write; pub mod net; pub mod presence; diff --git a/crates/flowstate-collab/tests/flow_runtime.rs b/crates/flowstate-collab/tests/flow_runtime.rs new file mode 100644 index 00000000..0b1e1095 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_runtime.rs @@ -0,0 +1,494 @@ +//! FlowRuntime write-path gates (build-order step 3): single-peer intents of +//! every class, no-op/rejection zero-mutation laws, undo/redo ping-pong per +//! class, undo-meta focus restore, and publish-queue convergence. Every +//! committed intent also runs the debug board audit (maintained board == +//! fresh materialization), so these tests double as derivation-law checks. + +use std::sync::Arc; + +use flowstate_collab::flow::{FlowDocHandle, FlowPublishEvent, FlowRuntime}; +use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; +use flowstate_flow::projection::FlowBoardProjection; +use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, SetMarksIntent, SetParagraphStyleIntent, SplitParagraphIntent, + TextAnchor, +}; +use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; +use uuid::Uuid; + +struct Fixture { + handle: Arc, + format: FlowFormat, + sheet: SheetId, +} + +fn fixture() -> Fixture { + let format = FlowFormat::policy_debate(); + let (handle, _gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("fresh flow runtime")); + let sheet = Uuid::new_v4(); + handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Case".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("create sheet"); + Fixture { handle, format, sheet } +} + +impl Fixture { + fn add_cell(&self, placement: CellPlacement) -> CellId { + let cell = Uuid::new_v4(); + self + .handle + .apply(FlowIntent::AddCell { + sheet_id: self.sheet, + cell_id: cell, + placement, + seed: CellSeed::Empty, + }) + .expect("add cell"); + cell + } + + fn board(&self) -> FlowBoardProjection { + self.handle.board_projection().expect("board projection") + } + + fn cell_ids(&self) -> Vec { + self.board().sheets[0].cells.iter().map(|cell| cell.id).collect() + } + + fn paragraph_id(&self, cell: CellId, ix: usize) -> gpui_flowtext::ParagraphId { + let projection = self.handle.open_cell(cell).expect("open cell"); + projection.ids.paragraph_ids[ix] + } + + fn set_content(&self, cell: CellId, texts: &[(&str, ParagraphStyle)]) { + let paragraphs = texts + .iter() + .map(|(text, style)| InputParagraph { + style: *style, + runs: vec![InputRun { + text: (*text).to_string(), + styles: RunStyles::default(), + }], + }) + .collect(); + self + .handle + .apply(FlowIntent::ReplaceCellContent { + sheet_id: self.sheet, + cell_id: cell, + paragraphs, + }) + .expect("replace cell content"); + } + + fn summary(&self, cell: CellId) -> String { + self + .board() + .cell(cell) + .map(|(_, cell)| cell.summary.summary_text.to_string()) + .expect("cell summary") + } +} + +fn stroke(sheet: SheetId, originator: &str) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::new_v4(), + sheet_id: sheet, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 2.0, + opacity: 0.5, + }, + bbox: BoardRect { + min: BoardPoint { x: 0.0, y: 0.0 }, + max: BoardPoint { x: 1.0, y: 1.0 }, + }, + } +} + +#[test] +fn structural_intents_maintain_the_board_in_place() { + let fx = fixture(); + // Placements: end, top, sibling, first/last response. + let first = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let top = fx.add_cell(CellPlacement::ColumnTop { column_index: 0 }); + let response = fx.add_cell(CellPlacement::ResponseTo { parent: first }); + let first_response = fx.add_cell(CellPlacement::FirstResponseTo { parent: first }); + let sibling = fx.add_cell(CellPlacement::Sibling { + of: first, + position: RelativePosition::After, + }); + assert_eq!(fx.cell_ids(), vec![top, first, first_response, response, sibling]); + let board = fx.board(); + assert_eq!(board.sheets[0].cell(response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(first_response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(sibling).unwrap().parent_id, None); + + // Subtree move: `first` (with its two responses) after `sibling`. + fx.handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: first, + drop: FlowDropIntent::AfterSibling(sibling), + }) + .expect("move subtree"); + assert_eq!(fx.cell_ids(), vec![top, sibling, first, first_response, response]); + + // Rename + move sheet + second sheet. + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Renamed".into(), + }) + .expect("rename"); + let second_sheet = Uuid::new_v4(); + fx.handle + .apply(FlowIntent::CreateSheet { + sheet_id: second_sheet, + name: "Other".into(), + sheet_type_id: fx.format.sheet_types[1].id, + }) + .expect("second sheet"); + fx.handle + .apply(FlowIntent::MoveSheet { + sheet_id: second_sheet, + target_index: 0, + }) + .expect("move sheet"); + let board = fx.board(); + assert_eq!( + board.sheets.iter().map(|sheet| sheet.id).collect::>(), + vec![second_sheet, fx.sheet] + ); + assert_eq!(board.sheet(fx.sheet).unwrap().name, "Renamed"); + + // Delete a parent: children orphan canonically (no defects on rebuild). + fx.handle + .apply(FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: first, + }) + .expect("delete cell"); + let board = fx.board(); + assert_eq!(board.sheet(fx.sheet).unwrap().cell(response).unwrap().parent_id, None); + assert_eq!(board.sheet(fx.sheet).unwrap().cell(first_response).unwrap().parent_id, None); + + fx.handle + .apply(FlowIntent::DeleteSheet { sheet_id: second_sheet }) + .expect("delete sheet"); + assert_eq!(fx.board().sheets.len(), 1); +} + +#[test] +fn cell_text_intents_drive_the_cell_stream_and_summary() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let authority = fx.handle.cell_authority(cell); + let projection = fx.handle.open_cell(cell).expect("open cell"); + assert_eq!(projection.paragraphs.len(), 1); + let paragraph = projection.ids.paragraph_ids[0]; + + use gpui_flowtext::local_intents::LocalWriteAuthority as _; + // Typing. + let outcome = authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "Warming oceans".into(), + style_override: None, + })) + .expect("insert text"); + let commit = outcome.commit(); + assert!(commit.selection_after.is_some(), "caret snapshot rides the outcome"); + let items = authority.drain_projection_stream().expect("drain"); + assert!(!items.is_empty(), "whole-cell Replace queued for the editor"); + assert_eq!(fx.summary(cell), "Warming oceans", "board summary refreshed"); + + // Split + style + marks. + authority + .apply(LocalIntent::SplitParagraph(SplitParagraphIntent { + at: TextAnchor::new(paragraph, 7), + inherited_style: ParagraphStyle::Normal, + })) + .expect("split"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 2); + let second = projection.ids.paragraph_ids[1]; + authority + .apply(LocalIntent::SetParagraphStyle(SetParagraphStyleIntent { + paragraph: second, + style: flowstate_document::PARAGRAPH_ANALYTIC, + })) + .expect("style"); + authority + .apply(LocalIntent::SetMarks(SetMarksIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 4), + styles: RunStyles { + strikethrough: true, + ..RunStyles::default() + }, + })) + .expect("marks"); + let projection = authority.canonical_projection().expect("projection"); + assert!(projection.paragraphs[0].runs.iter().any(|run| run.styles.strikethrough)); + assert_eq!(projection.paragraphs[1].style, flowstate_document::PARAGRAPH_ANALYTIC); + + // Join back + delete a range. + authority + .apply(LocalIntent::JoinParagraphs(JoinParagraphsIntent { + first: paragraph, + second, + })) + .expect("join"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 1); + authority + .apply(LocalIntent::DeleteRange(DeleteRangeIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 8), + })) + .expect("delete range"); + assert_eq!(fx.summary(cell), "oceans"); +} + +#[test] +fn strike_and_editable_and_annotations() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("argument", ParagraphStyle::Normal)]); + assert!(!fx.board().cell(cell).unwrap().1.summary.uses_summary_projection); + + // Strike on, idempotent no-op, strike off. + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike"); + assert!(fx.board().cell(cell).unwrap().1.summary.struck); + let outcome = fx + .handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike no-op"); + assert!(!outcome.changed, "same-state strike resolves to a no-op"); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: false, + }) + .expect("unstrike"); + assert!(!fx.board().cell(cell).unwrap().1.summary.struck); + + // EnsureCellEditable restyles the first paragraph to the tag style. + fx.handle + .apply(FlowIntent::EnsureCellEditable { + sheet_id: fx.sheet, + cell_id: cell, + }) + .expect("ensure editable"); + assert!(fx.board().cell(cell).unwrap().1.summary.uses_summary_projection); + + // Annotations: add two originators, clear one, delete respects ownership. + let mine = stroke(fx.sheet, "me"); + let theirs = stroke(fx.sheet, "them"); + for annotation in [&mine, &theirs] { + fx.handle + .apply(FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: annotation.clone(), + }) + .expect("add annotation"); + } + let refused = fx + .handle + .apply(FlowIntent::DeleteAnnotation { + sheet_id: fx.sheet, + stroke_id: theirs.id, + originator: AnnotationOriginator("me".into()), + }) + .expect("delete annotation (foreign)"); + assert!(!refused.changed, "cannot delete another originator's stroke"); + fx.handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: Some(fx.sheet), + originator: AnnotationOriginator("me".into()), + }) + .expect("clear annotations"); + let board = fx.board(); + assert_eq!(board.sheets[0].annotations.len(), 1); + assert_eq!(board.sheets[0].annotations[0].originator, AnnotationOriginator("them".into())); +} + +#[test] +fn rejections_and_no_ops_leave_the_frontier_untouched() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let frontier = fx.handle.board_projection().expect("board"); + let before = fx + .handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: None, + originator: AnnotationOriginator("nobody".into()), + }) + .expect("clear nothing"); + assert!(!before.changed); + + // Unknown sheet/cell reject. + assert!( + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: Uuid::new_v4(), + name: "ghost".into(), + }) + .is_err() + ); + assert!( + fx.handle + .apply(FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: Uuid::new_v4(), + }) + .is_err() + ); + // Self-drop move rejects. + assert!( + fx.handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: cell, + drop: FlowDropIntent::LastChildOf(cell), + }) + .is_err() + ); + assert_eq!(fx.handle.board_projection().expect("board"), frontier, "no mutation escaped"); +} + +#[test] +fn undo_redo_ping_pong_across_intent_classes() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("tag line", flowstate_document::PARAGRAPH_TAG)]); + let other = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + + // A representative mutation per class, applied in sequence. + let steps: Vec = vec![ + FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Undoable".into(), + }, + FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: other, + drop: FlowDropIntent::BeforeSibling(cell), + }, + FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }, + FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: stroke(fx.sheet, "me"), + }, + FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: other, + }, + FlowIntent::CellText { + cell_id: cell, + intent: LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(fx.paragraph_id(cell, 0), 0), + text: "typed ".into(), + style_override: None, + }), + }, + ]; + + let mut boards = vec![fx.board()]; + for step in steps { + fx.handle.apply(step).expect("apply step"); + boards.push(fx.board()); + } + + // Undo all the way down the stack, checking each intermediate board. + for expected in boards.iter().rev().skip(1) { + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied, "undo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "undo restored the exact prior board"); + } + // Redo all the way back up. + for expected in boards.iter().skip(1) { + let outcome = fx.handle.redo().expect("redo"); + assert!(outcome.applied, "redo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "redo restored the exact next board"); + } +} + +#[test] +fn undo_meta_restores_board_focus_context() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.handle.set_undo_context(flowstate_collab::flow::runtime::FlowUndoMeta { + active_sheet: Some(fx.sheet), + focused_cell: Some(cell), + head_cursor: None, + anchor_cursor: None, + }); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike under context"); + fx.set_content(cell, &[("x", ParagraphStyle::Normal)]); // later commit with same context + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied); + let meta = outcome.meta.expect("undo meta restored"); + assert_eq!(meta.active_sheet, Some(fx.sheet)); + assert_eq!(meta.focused_cell, Some(cell)); +} + +#[test] +fn publish_queue_converges_a_second_runtime() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("shared", flowstate_document::PARAGRAPH_TAG)]); + + // Bootstrap peer B from a snapshot, then apply more local edits on A. + let snapshot = fx + .handle + .with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let (peer_b, _gate_b) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer B")); + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Post-snapshot".into(), + }) + .expect("rename"); + + // Drain A's publish queue and import the update bytes into B. + let events = fx.handle.with_test_runtime(FlowRuntime::take_pending_publish); + let mut imported = false; + for event in events { + if let FlowPublishEvent::LocalUpdate { bytes, .. } = event { + peer_b.with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()]).expect("import")); + imported = true; + } + } + assert!(imported, "local commits queued publishable updates"); + assert_eq!(peer_b.board_projection().expect("board"), fx.board(), "peers converged"); +} diff --git a/crates/flowstate-flow/src/loro_schema.rs b/crates/flowstate-flow/src/loro_schema.rs index c9ca207d..069c5262 100644 --- a/crates/flowstate-flow/src/loro_schema.rs +++ b/crates/flowstate-flow/src/loro_schema.rs @@ -25,7 +25,7 @@ use flowstate_document::loro_schema::{ FLOW_TEXT_KEY, MARK_PARAGRAPH_STYLE, PARAGRAPHS_BY_ID, ROOT_FIRST_PARAGRAPH_ID, configure_text_styles, ensure_flow, }; use gpui_flowtext::{InputParagraph, ParagraphStyle, RunSemanticStyle}; -use loro::{LoroDoc, LoroMap, LoroMovableList, LoroResult, LoroText, LoroValue, ValueOrContainer, cursor::Side}; +use loro::{ContainerTrait as _, LoroDoc, LoroMap, LoroMovableList, LoroResult, LoroText, LoroValue, ValueOrContainer, cursor::Side}; use uuid::Uuid; use crate::format::{AnnotationStroke, CellId, FlowFormat, SheetId, StrokeId}; @@ -142,6 +142,38 @@ pub fn cell_flow_label(cell_id: CellId) -> String { format!("cell.{cell_id}.flow") } +/// READ-ONLY text resolution for a flow map (never creates containers): +/// prefer the stored raw container id, fall back to the `text` child. +pub fn flow_text(doc: &LoroDoc, flow: &LoroMap) -> Option { + if let Some(container_id) = map_string(flow, "text_container_id") + && let Ok(id) = loro::ContainerID::try_from(container_id.as_str()) + && let Some(container) = doc.get_container(id) + && container.is_attached() + && !container.is_deleted() + && let Ok(text) = container.into_text() + { + return Some(text); + } + match flow.get(FLOW_TEXT_KEY)? { + ValueOrContainer::Container(container) => container.into_text().ok(), + ValueOrContainer::Value(_) => None, + } +} + +/// READ-ONLY cell text resolution (`cell → flow → text`). +pub fn cell_text(doc: &LoroDoc, cell_id: CellId) -> Option { + let cell = cell_map(doc, cell_id)?; + let flow = cell_flow_map(&cell)?; + flow_text(doc, &flow) +} + +/// READ-ONLY per-cell paragraph registry resolution. +pub fn cell_paragraph_registry(doc: &LoroDoc, cell_id: CellId) -> Option { + let cell = cell_map(doc, cell_id)?; + let flow = cell_flow_map(&cell)?; + child_map(&flow, PARAGRAPHS_BY_ID) +} + // ---- Sheet records --------------------------------------------------------- /// Create (or converge on) a sheet record and insert it into the board order diff --git a/crates/flowstate-flow/tests/v2_board.rs b/crates/flowstate-flow/tests/v2_board.rs index 60588e91..f6a817e3 100644 --- a/crates/flowstate-flow/tests/v2_board.rs +++ b/crates/flowstate-flow/tests/v2_board.rs @@ -412,6 +412,31 @@ fn normalization_determinism_under_random_concurrent_raw_mutation() { } } +/// The old `Cell::summary_text` fixture, verbatim: mixed card + analytic +/// content projects tag/cite/undertag/analytic rows in document order. +#[test] +fn summary_projects_mixed_card_and_analytic_content_in_document_order() { + let cite = |text: &str| { + let mut run = run(text); + run.styles.semantic = flowstate_document::SEMANTIC_CITE; + run + }; + let blocks: Vec = vec![ + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph( + ParagraphStyle::Normal, + vec![run("hidden before "), cite("Cite"), run(" hidden after")], + )), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_UNDERTAG, vec![run("Undertag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_ANALYTIC, vec![run("Analytic")])), + ]; + let summary = summary_from_rows(&blocks); + assert_eq!(summary.summary_text.as_ref(), "Tag\nCite\nUndertag\nAnalytic"); + assert!(summary.uses_summary_projection); + assert!(!summary.struck); + assert!(!summary.is_empty); +} + #[test] fn fl0_v2_round_trips_and_v1_is_rejected() { let board = board_with_sheet(); From 50a506dcd280771f232b5cfdda17ad2211cdcf52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:39:41 +0000 Subject: [PATCH 17/24] flowstate-collab: flow convergence fuzzes + canonical-order write invariant flow_convergence.rs (N-peer random structural intents, self-consistency vs fresh materialization + cross-peer board equality, fixed CI seeds + ignored soak) and flow_cell_text_fuzz.rs (concurrent same-cell edits through real FlowCellAuthority paths, reorder-vs-edit arm, plus the two headline defects as deterministic tests). The fuzz surfaced a real law gap the debug audit caught: post-merge raw order lists sit in non-canonical interleavings, so index-based writes diverged from the materialized view. Fixes: (1) structural intents re-linearize canonically INSIDE their own commit, reconciling the raw list against liveness drift (dead/dup/missing entries); (2) the drag preview applies the identical canonicalization (one law, preview == commit); (3) imports run a capped 'repair'-origin order-canonicalization pass mirroring schedule_projection_repairs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-collab/src/flow/commit.rs | 116 +++++- crates/flowstate-collab/src/flow/runtime.rs | 56 ++- .../tests/flow_cell_text_fuzz.rs | 346 ++++++++++++++++++ .../tests/flow_convergence.rs | 299 +++++++++++++++ crates/flowstate-flow/src/board_ops.rs | 51 ++- 5 files changed, 858 insertions(+), 10 deletions(-) create mode 100644 crates/flowstate-collab/tests/flow_cell_text_fuzz.rs create mode 100644 crates/flowstate-collab/tests/flow_convergence.rs diff --git a/crates/flowstate-collab/src/flow/commit.rs b/crates/flowstate-collab/src/flow/commit.rs index 5e98aa58..e16ce008 100644 --- a/crates/flowstate-collab/src/flow/commit.rs +++ b/crates/flowstate-collab/src/flow/commit.rs @@ -227,6 +227,7 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> }, ); } + canonicalize_sheet_order(core, doc, *sheet_id)?; Ok(Some(Derived::Board)) }, FlowIntent::DeleteCell { sheet_id, cell_id } => { @@ -256,6 +257,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> } } } + // Orphaned children may now interleave another subtree's span; restore + // the canonical linearization inside this same commit. + canonicalize_sheet_order(core, doc, *sheet_id)?; Ok(Some(Derived::Board)) }, FlowIntent::MoveCellSubtree { sheet_id, cell_id, drop } => { @@ -274,6 +278,10 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> if !board_ops::sheet_topology_ok(&target, &columns) { return Err(FlowWriteRejected::StructureViolation("move would violate sheet topology".into()).into()); } + // The committed order is the canonical linearization of the moved sheet + // (an arbitrary-index root drop can land inside another subtree's span); + // the drag preview applies the identical step. + board_ops::canonicalize_sheet(&mut target); let before = sheet.clone(); let sheet_map = loro_schema::sheet_map(doc, *sheet_id).context("resolving sheet map")?; // Column/parent rewrites for changed subtree members (LWW fields). @@ -514,13 +522,63 @@ fn resolve_placement( } } -/// Transform the live cell order into `target` with a minimal `mov` sequence -/// (only displaced elements move; a subtree move emits one `mov` per member). +/// Re-linearize a sheet canonically after a structural mutation, in BOTH +/// representations inside the same commit: the maintained board's cell vec and +/// the raw Loro order list. Keeps "raw order == canonical order" a write-path +/// invariant instead of a view-time repair, so the materializer's DFS +/// normalization is a no-op on locally-produced states. +fn canonicalize_sheet_order(core: &mut FlowRuntime, doc: &LoroDoc, sheet_id: SheetId) -> Result<(), ExecuteError> { + let Some(sheet) = core.board_mut().sheet_mut(sheet_id) else { + return Ok(()); + }; + board_ops::canonicalize_sheet(sheet); + let target: Vec = sheet.cells.iter().map(|cell| cell.id.to_string()).collect(); + let sheet_map = loro_schema::sheet_map(doc, sheet_id).context("resolving sheet map for canonicalization")?; + // ALWAYS diff against the live raw order: after a merge, raw may sit in a + // non-canonical interleaving (normalized only in the materialized view), so + // "maintained unchanged" does not imply "raw already matches". + if loro_schema::cell_order_ids(&sheet_map) != target { + apply_order_diff(&sheet_map, &target).context("re-linearizing sheet order")?; + } + Ok(()) +} + +/// Reconcile the live cell order onto `target`: drop dead/duplicate entries, +/// append missing ones, then a minimal `mov` sequence (only displaced elements +/// move; a subtree move emits one `mov` per member). Handles post-merge raw +/// orders that carry liveness drift the materialized view already normalized. fn apply_order_diff(sheet_map: &loro::LoroMap, target: &[String]) -> Result<()> { - let mut working = loro_schema::cell_order_ids(sheet_map); + let order = loro_schema::cell_order_list(sheet_map).context("resolving cell order list")?; + reconcile_order_list(&order, &loro_schema::cell_order_ids(sheet_map), target) +} + +/// The generic movable-list reconcile behind [`apply_order_diff`], shared with +/// the import-side canonicalization repair (board sheet order + cell orders). +pub(crate) fn reconcile_order_list(order: &loro::LoroMovableList, current: &[String], target: &[String]) -> Result<()> { + let mut working = current.to_vec(); + let target_set: std::collections::HashSet<&String> = target.iter().collect(); + // Dead or duplicate entries, removed back-to-front so indices stay live. + let mut seen: std::collections::HashSet<&String> = std::collections::HashSet::new(); + let mut doomed: Vec = Vec::new(); + for (index, id) in working.iter().enumerate() { + if !target_set.contains(id) || !seen.insert(id) { + doomed.push(index); + } + } + for index in doomed.into_iter().rev() { + order.delete(index, 1).context("dropping dead order entry")?; + working.remove(index); + } + // Missing target entries append (their canonical position lands via `mov`). + for id in target { + if !working.iter().any(|entry| entry == id) { + order.insert(working.len(), id.as_str()).context("appending missing order entry")?; + working.push(id.clone()); + } + } anyhow::ensure!( working.len() == target.len(), - "order diff over mismatched sets ({} live vs {} target)", + "order reconcile left mismatched sets ({} live vs {} target)", working.len(), target.len() ); @@ -532,13 +590,61 @@ fn apply_order_diff(sheet_map: &loro::LoroMap, target: &[String]) -> Result<()> .iter() .position(|id| *id == target[index]) .context("target order entry missing from live order")?; - loro_schema::move_cell_order(sheet_map, from, index)?; + order.mov(from, index).context("moving order entry")?; let id = working.remove(from); working.insert(index, id); } Ok(()) } +/// Import-side canonicalization repair (the flow mirror of +/// `schedule_projection_repairs`): rewrite the raw sheet/cell order lists to +/// the canonical linearization the materialized view already shows, under the +/// `"repair"` origin (undo-excluded), publishing the pass like any commit. +/// Returns whether anything was rewritten. The caller caps invocations. +pub(crate) fn repair_canonical_orders(core: &mut FlowRuntime) -> Result { + let doc = core.doc().clone(); + let vv_before = doc.oplog_vv(); + let mut changed = false; + + let target_sheets: Vec = core + .board_ref() + .sheets + .iter() + .map(|sheet| sheet.id.to_string()) + .collect(); + let sheet_order = loro_schema::sheet_order(&doc); + let current_sheets = loro_schema::sheet_order_ids(&doc); + if current_sheets != target_sheets { + reconcile_order_list(&sheet_order, ¤t_sheets, &target_sheets).context("repairing sheet order")?; + changed = true; + } + + let sheets: Vec<(SheetId, Vec)> = core + .board_ref() + .sheets + .iter() + .map(|sheet| (sheet.id, sheet.cells.iter().map(|cell| cell.id.to_string()).collect())) + .collect(); + for (sheet_id, target) in sheets { + let Some(sheet_map) = loro_schema::sheet_map(&doc, sheet_id) else { + continue; + }; + if loro_schema::cell_order_ids(&sheet_map) != target { + apply_order_diff(&sheet_map, &target).context("repairing cell order")?; + changed = true; + } + } + + if changed { + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-order-canonicalization"); + doc.commit(); + core.queue_local_update_publish(&vv_before); + } + Ok(changed) +} + /// Cursor-backed caret snapshot after a cell-text intent (spec §8 shape). fn cell_selection_snapshot(doc: &LoroDoc, cell: CellId, caret_unicode: usize) -> Option { let text = loro_schema::cell_text(doc, cell)?; diff --git a/crates/flowstate-collab/src/flow/runtime.rs b/crates/flowstate-collab/src/flow/runtime.rs index 3b7ec5c0..7c4141a1 100644 --- a/crates/flowstate-collab/src/flow/runtime.rs +++ b/crates/flowstate-collab/src/flow/runtime.rs @@ -136,6 +136,8 @@ pub struct FlowRuntime { undo_meta: Arc>, /// Latest normalization defects (repair-pass input; capped by stable key). pub(crate) defects: Vec, + /// Remaining import-side order-canonicalization repair passes (loop guard). + order_repairs_remaining: u32, } impl FlowRuntime { @@ -187,6 +189,7 @@ impl FlowRuntime { undo, undo_meta, defects, + order_repairs_remaining: 64, }) } @@ -401,10 +404,12 @@ impl FlowRuntime { pub(crate) fn audit_board_against_rebuild(&self, class: &str) { match materialize_board(&self.doc) { Ok((fresh, _)) => { - assert!( - fresh == self.board, - "flow intent '{class}' left the maintained board out of sync with a fresh materialization" - ); + if fresh != self.board { + panic!( + "flow intent '{class}' left the maintained board out of sync with a fresh materialization:\n{}", + board_divergence(&self.board, &fresh) + ); + } }, Err(error) => panic!("flow board audit rematerialization failed after '{class}': {error:#}"), } @@ -425,6 +430,18 @@ impl FlowRuntime { return Ok(()); } self.refresh_all()?; + // Capped canonicalization repair (spec Part A): when the merge left the + // raw order lists in a non-canonical interleaving, rewrite them (origin + // "repair") to the canonical linearization every peer already renders. + // Deterministic-target rewrites converge; the cap stops any pathological + // repair ping-pong from spinning. + if self.order_repairs_remaining > 0 { + match super::commit::repair_canonical_orders(self) { + Ok(true) => self.order_repairs_remaining -= 1, + Ok(false) => {}, + Err(error) => tracing::error!(%error, "flow order canonicalization repair failed"), + } + } let event = FlowPublishEvent::RemoteUpdateApplied { frontier: self.frontier(), version_vector: self.doc.state_vv().encode(), @@ -494,6 +511,37 @@ impl FlowRuntime { } } +/// Debug-audit diagnostics: the first field-level difference between the +/// maintained and freshly-materialized boards. +#[cfg(debug_assertions)] +fn board_divergence(maintained: &FlowBoardProjection, fresh: &FlowBoardProjection) -> String { + if maintained.format != fresh.format { + return "format differs".to_string(); + } + if maintained.sheets.len() != fresh.sheets.len() { + return format!("sheet count {} vs fresh {}", maintained.sheets.len(), fresh.sheets.len()); + } + for (ours, theirs) in maintained.sheets.iter().zip(&fresh.sheets) { + if ours.id != theirs.id || ours.name != theirs.name || ours.sheet_type_id != theirs.sheet_type_id { + return format!("sheet header {:?} vs fresh {:?}", (ours.id, &ours.name), (theirs.id, &theirs.name)); + } + if ours.annotations != theirs.annotations { + return format!("sheet {} annotations differ", ours.id); + } + let our_ids: Vec<_> = ours.cells.iter().map(|cell| cell.id).collect(); + let their_ids: Vec<_> = theirs.cells.iter().map(|cell| cell.id).collect(); + if our_ids != their_ids { + return format!("sheet {} order {our_ids:?} vs fresh {their_ids:?}", ours.id); + } + for (our_cell, their_cell) in ours.cells.iter().zip(&theirs.cells) { + if our_cell != their_cell { + return format!("cell {} maintained {our_cell:?} vs fresh {their_cell:?}", our_cell.id); + } + } + } + "boards differ in an uncompared field".to_string() +} + fn install_undo_meta_callbacks(undo: &UndoManager, state: &Arc>) { let push_state = Arc::clone(state); undo.set_on_push(Some(Box::new(move |_, _, _| { diff --git a/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs new file mode 100644 index 00000000..5e770d55 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs @@ -0,0 +1,346 @@ +//! Same-cell concurrent rich-text fuzz (build-order step 5) — the two headline +//! defects asserted dead: +//! 1. concurrent char-level edits INSIDE one cell interleave (no +//! last-writer-wins blob clobber); +//! 2. a reorder can NEVER lose a concurrent keystroke (order-list writes never +//! touch text containers). +//! +//! Peers drive the REAL write path: `FlowCellAuthority` → `FlowIntent::CellText` +//! → gated commit, exactly what an attached `RichTextEditor` does. + +use std::sync::Arc; + +use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; +use flowstate_collab::local_write::WriteGate; +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; +use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, LocalWriteAuthority as _, SetMarksIntent, SplitParagraphIntent, + TextAnchor, WriteRejected, +}; +use gpui_flowtext::{ParagraphStyle, RunStyles}; +use uuid::Uuid; + +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } +} + +struct Peer { + handle: Arc, + authority: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, +} + +impl Peer { + fn oplog_vv(&self) -> loro::VersionVector { + self.handle.with_test_runtime(|runtime| runtime.oplog_version_vector()) + } + + fn cell_text(&self, cell: CellId) -> String { + self.handle.with_test_runtime(|runtime| { + let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); + rows + .blocks + .iter() + .filter_map(|block| match block { + gpui_flowtext::InputBlock::Paragraph(paragraph) => { + Some(paragraph.runs.iter().map(|run| run.text.as_str()).collect::()) + }, + _ => None, + }) + .collect::>() + .join("\n") + }) + } +} + +fn sync_all(peers: &[Peer]) { + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + if let Ok(bytes) = peers[a].handle.with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } + } + } + } +} + +fn spawn(peers_n: usize) -> (Vec, SheetId, CellId) { + let format = FlowFormat::policy_debate(); + let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("seed runtime")); + let sheet = Uuid::new_v4(); + let cell = Uuid::new_v4(); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Text fuzz".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("sheet"); + for column_index in 0..2 { + // A second cell so reorders have something to move against. + seed_handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: if column_index == 0 { cell } else { Uuid::new_v4() }, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .expect("cell"); + } + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let peers = (0..peers_n) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer")); + let authority = handle.cell_authority(cell); + let _ = handle.open_cell(cell).expect("open cell"); + Peer { handle, authority, gate } + }) + .collect(); + (peers, sheet, cell) +} + +/// One random cell-text intent through the peer's authority. Expected +/// rejections (anchors moved by concurrent edits) are fine. +fn random_text_intent(rng: &mut Rng, peer: &Peer, step: usize) -> Result<(), WriteRejected> { + let projection = peer.authority.canonical_projection()?; + let paragraph_count = projection.paragraphs.len(); + let pick_anchor = |rng: &mut Rng| { + let ix = rng.below(paragraph_count); + let id = projection.ids.paragraph_ids[ix]; + let len = flowstate_document::paragraph_text_len(&projection.paragraphs[ix]); + TextAnchor::new(id, rng.below(len + 1)) + }; + let intent = match rng.below(6) { + 0 | 1 => LocalIntent::InsertText(InsertTextIntent { + at: pick_anchor(rng), + text: format!("w{step} "), + style_override: None, + }), + 2 => LocalIntent::DeleteRange(DeleteRangeIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + }), + 3 => LocalIntent::SplitParagraph(SplitParagraphIntent { + at: pick_anchor(rng), + inherited_style: ParagraphStyle::Normal, + }), + 4 => { + if paragraph_count < 2 { + return Ok(()); + } + let first_ix = rng.below(paragraph_count - 1); + LocalIntent::JoinParagraphs(JoinParagraphsIntent { + first: projection.ids.paragraph_ids[first_ix], + second: projection.ids.paragraph_ids[first_ix + 1], + }) + }, + _ => LocalIntent::SetMarks(SetMarksIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + styles: RunStyles { + strikethrough: rng.below(2) == 0, + direct_underline: rng.below(2) == 0, + ..RunStyles::default() + }, + }), + }; + peer.authority.apply(intent).map(|_| ()) +} + +fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let (peers, sheet, cell) = spawn(peers_n); + + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + // Interleave the occasional structural reorder with the typing storm — + // the reorder-vs-edit headline arm. + if rng.below(8) == 0 { + let board = peers[peer_ix].handle.board_projection().expect("board"); + let cells: Vec = board.sheet(sheet).expect("sheet").cells.iter().map(|cell| cell.id).collect(); + if cells.len() > 1 { + let target = cells[rng.below(cells.len())]; + if target != cell { + let _ = peers[peer_ix].handle.apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: if rng.below(2) == 0 { + FlowDropIntent::BeforeSibling(target) + } else { + FlowDropIntent::AfterSibling(target) + }, + }); + } + } + } + match random_text_intent(&mut rng, &peers[peer_ix], step) { + Ok(()) + | Err(WriteRejected::EmptyIntent | WriteRejected::StructureViolation(_) | WriteRejected::UnresolvedParagraph(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } + } + sync_all(&peers); + + let reference_vv = peers[0].oplog_vv(); + let reference_text = peers[0].cell_text(cell); + let reference_board = peers[0].handle.board_projection().expect("board"); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!( + peer.cell_text(cell), + reference_text, + "seed {seed} round {round}: peer {ix} cell text diverged" + ); + assert_eq!( + peer.handle.board_projection().expect("board"), + reference_board, + "seed {seed} round {round}: peer {ix} board diverged" + ); + } + } +} + +/// THE headline pair, deterministic form: peer A types while peer B reorders +/// the same cell; both edits survive the merge exactly. +#[test] +fn reorder_never_loses_a_concurrent_keystroke() { + let (peers, sheet, cell) = spawn(2); + let projection = peers[0].authority.canonical_projection().expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; + + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "surviving keystrokes".into(), + style_override: None, + })) + .expect("typing"); + let board = peers[1].handle.board_projection().expect("board"); + let other = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|candidate| candidate.id) + .find(|id| *id != cell) + .expect("other cell"); + peers[1] + .handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: FlowDropIntent::AfterSibling(other), + }) + .expect("reorder"); + + sync_all(&peers); + for peer in &peers { + assert_eq!(peer.cell_text(cell), "surviving keystrokes"); + let board = peer.handle.board_projection().expect("board"); + let order: Vec = board.sheet(sheet).expect("sheet").cells.iter().map(|cell| cell.id).collect(); + assert_eq!(order, vec![other, cell], "the reorder survived alongside the keystrokes"); + } +} + +#[test] +fn concurrent_same_cell_edits_interleave() { + let (peers, _sheet, cell) = spawn(2); + let projection = peers[0].authority.canonical_projection().expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "left".into(), + style_override: None, + })) + .expect("peer 0 types"); + peers[1] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "right".into(), + style_override: None, + })) + .expect("peer 1 types"); + sync_all(&peers); + let merged = peers[0].cell_text(cell); + assert_eq!(merged, peers[1].cell_text(cell)); + assert!( + merged.contains("left") && merged.contains("right"), + "both peers' characters survive char-level merge: {merged:?}" + ); +} + +#[test] +fn two_peer_cell_text_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } +} + +#[test] +fn three_peer_cell_text_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } +} + +/// Long-horizon soak (10k+ text intents across the sweep), ignored by default. +#[test] +#[ignore = "long-horizon soak — run via `heaven.sh soak`"] +fn flow_cell_text_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 2) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_cell_text {seed}/{seeds}"); + } + } +} diff --git a/crates/flowstate-collab/tests/flow_convergence.rs b/crates/flowstate-collab/tests/flow_convergence.rs new file mode 100644 index 00000000..433d2302 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_convergence.rs @@ -0,0 +1,299 @@ +//! Flow board convergence fuzz (build-order step 5): N peers apply random +//! STRUCTURAL intents through their gated flow handles, sync in rounds, and +//! must converge — every peer's maintained board equal to every other's AND +//! equal to a fresh materialization of its own canonical state (the one +//! derivation law). Mirrors the .db8 `intent_fuzz` harness shape: fixed CI +//! seeds + an ignored long-horizon soak. + +use std::sync::Arc; + +use flowstate_collab::flow::{FlowDocHandle, FlowRuntime, FlowWriteRejected}; +use flowstate_collab::local_write::WriteGate; +use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; +use flowstate_flow::loro_projection::materialize_board; +use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; +use uuid::Uuid; + +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } +} + +struct Peer { + handle: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, +} + +impl Peer { + fn board(&self) -> flowstate_flow::projection::FlowBoardProjection { + self.handle.board_projection().expect("board") + } + + fn oplog_vv(&self) -> loro::VersionVector { + self.handle.with_test_runtime(|runtime| runtime.oplog_version_vector()) + } +} + +fn spawn_peers(count: usize, format: &FlowFormat, base_sheet: SheetId) -> Vec { + let seed_runtime = FlowRuntime::new(format).expect("seed runtime"); + let (seed_handle, _seed_gate) = FlowDocHandle::new(seed_runtime); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: base_sheet, + name: "Fuzz".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("seed sheet"); + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("seed snapshot")); + (0..count) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer runtime")); + Peer { handle, gate } + }) + .collect() +} + +fn sync_all(peers: &[Peer]) { + // Full mesh, two passes → quiescent for any peer count used here. + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + let bytes = peers[a].handle.with_test_runtime(|runtime| runtime.export_updates_for(&vv)); + if let Ok(bytes) = bytes + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } + } + } + } +} + +fn stroke(sheet: SheetId, originator: &str, rng: &mut Rng) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + sheet_id: sheet, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 1.0, + opacity: 1.0, + }, + bbox: BoardRect::default(), + } +} + +/// One random structural intent against `peer`. Rejections from racing +/// deletions are expected; anything else is a bug. +fn random_intent(rng: &mut Rng, peer: &Peer, sheet: SheetId, step: usize) -> Result<(), FlowWriteRejected> { + let board = peer.board(); + let cells: Vec = board + .sheet(sheet) + .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) + .unwrap_or_default(); + let pick = |rng: &mut Rng, cells: &[CellId]| cells.get(rng.below(cells.len())).copied(); + let intent = match rng.below(10) { + 0 => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: CellPlacement::ColumnEnd { + column_index: rng.below(3), + }, + seed: CellSeed::Empty, + }, + 1 => match pick(rng, &cells) { + Some(parent) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: if rng.below(2) == 0 { + CellPlacement::ResponseTo { parent } + } else { + CellPlacement::FirstResponseTo { parent } + }, + seed: CellSeed::Empty, + }, + None => return Ok(()), + }, + 2 => match pick(rng, &cells) { + Some(of) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: CellPlacement::Sibling { + of, + position: if rng.below(2) == 0 { + RelativePosition::Before + } else { + RelativePosition::After + }, + }, + seed: CellSeed::Empty, + }, + None => return Ok(()), + }, + 3 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::DeleteCell { sheet_id: sheet, cell_id }, + None => return Ok(()), + }, + 4 => match (pick(rng, &cells), pick(rng, &cells)) { + (Some(cell_id), Some(target)) if cell_id != target => FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id, + drop: match rng.below(5) { + 0 => FlowDropIntent::BeforeSibling(target), + 1 => FlowDropIntent::AfterSibling(target), + 2 => FlowDropIntent::FirstChildOf(target), + 3 => FlowDropIntent::LastChildOf(target), + _ => FlowDropIntent::RootInColumn { + column_index: rng.below(3), + insertion_index: rng.below(cells.len() + 1), + }, + }, + }, + _ => return Ok(()), + }, + 5 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::SetCellStruck { + sheet_id: sheet, + cell_id, + struck: rng.below(2) == 0, + }, + None => return Ok(()), + }, + 6 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::ReplaceCellContent { + sheet_id: sheet, + cell_id, + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Custom(3), + runs: vec![InputRun { + text: format!("edit {step}"), + styles: RunStyles::default(), + }], + }], + }, + None => return Ok(()), + }, + 7 => FlowIntent::AddAnnotation { + sheet_id: sheet, + stroke: stroke(sheet, "fuzz", rng), + }, + 8 => FlowIntent::ClearAnnotations { + sheet_id: Some(sheet), + originator: AnnotationOriginator("fuzz".into()), + }, + _ => FlowIntent::RenameSheet { + sheet_id: sheet, + name: format!("Fuzz {step}"), + }, + }; + peer.handle.apply(intent).map(|_| ()) +} + +fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let format = FlowFormat::policy_debate(); + let sheet = Uuid::new_v4(); + let peers = spawn_peers(peers_n, &format, sheet); + + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + match random_intent(&mut rng, &peers[peer_ix], sheet, step) { + Ok(()) + | Err(FlowWriteRejected::EmptyIntent | FlowWriteRejected::StructureViolation(_)) + | Err(FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } + } + sync_all(&peers); + + // Self-consistency: maintained board == fresh materialization. + for (ix, peer) in peers.iter().enumerate() { + let maintained = peer.board(); + let fresh = peer + .handle + .with_test_runtime(|runtime| materialize_board(runtime.doc()).expect("fresh materialize").0); + assert_eq!( + maintained, fresh, + "seed {seed} round {round}: peer {ix} maintained board deviates from its own canonical state" + ); + } + // Cross-peer convergence. + let reference = peers[0].board(); + let reference_vv = peers[0].oplog_vv(); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!(peer.board(), reference, "seed {seed} round {round}: peer {ix} board diverged"); + } + } +} + +#[test] +fn two_peer_flow_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } +} + +#[test] +fn three_peer_flow_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } +} + +/// Long-horizon randomized soak (10k+ intents across the sweep), ignored by +/// default like the .db8 soaks — run via `heaven.sh soak`; tune with +/// `FUZZ_SOAK_SEEDS`/`FUZZ_SOAK_ROUNDS`/`FUZZ_SOAK_OPS`. +#[test] +#[ignore = "long-horizon soak — run via `heaven.sh soak`"] +fn flow_fuzz_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 3) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_convergence {seed}/{seeds}"); + } + } +} diff --git a/crates/flowstate-flow/src/board_ops.rs b/crates/flowstate-flow/src/board_ops.rs index d1508501..af64d589 100644 --- a/crates/flowstate-flow/src/board_ops.rs +++ b/crates/flowstate-flow/src/board_ops.rs @@ -300,6 +300,49 @@ pub fn resolve_drop_intent( } } +/// The canonical DFS linearization (the materializer's normalization step 6 +/// as a pure function): roots in current flat order, each followed by its +/// whole subtree, children in flat order. The write path re-linearizes after +/// any structural mutation that can interleave subtrees (delete-orphaning, an +/// arbitrary-index drop), so canonical order stays an invariant of the raw +/// order list rather than a view-time repair. +#[must_use] +pub fn canonical_linearization(cells: &[Cell]) -> Vec { + let mut children: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + for cell in cells { + match cell.parent_id { + Some(parent) => children.entry(parent).or_default().push(cell.id), + None => roots.push(cell.id), + } + } + let mut canonical = Vec::with_capacity(cells.len()); + let mut stack: Vec = roots.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + canonical.push(id); + if let Some(kids) = children.get(&id) { + stack.extend(kids.iter().rev()); + } + } + canonical +} + +/// Reorder `sheet.cells` into the canonical linearization. Returns whether +/// anything moved. +pub fn canonicalize_sheet(sheet: &mut Sheet) -> bool { + let canonical = canonical_linearization(&sheet.cells); + let current: Vec = sheet.cells.iter().map(|cell| cell.id).collect(); + if canonical == current { + return false; + } + let mut by_id: HashMap = sheet.cells.drain(..).map(|cell| (cell.id, cell)).collect(); + sheet.cells = canonical + .into_iter() + .filter_map(|id| by_id.remove(&id)) + .collect(); + true +} + /// Read-only preview of a subtree move: the sheet with the drag already /// applied, or `None` if the move is invalid (self/descendant drop, a /// descendant outside the columns, or a landing the committed move would @@ -309,7 +352,13 @@ pub fn preview_move_cell_subtree(board: &FlowBoardProjection, sheet_id: SheetId, let column_ids = sheet_column_ids(board, sheet_id).ok()?; let mut sheet = board.sheet(sheet_id)?.clone(); apply_move_subtree(&mut sheet, &column_ids, cell_id, intent).ok()?; - sheet_topology_ok(&sheet, &column_ids).then_some(sheet) + if !sheet_topology_ok(&sheet, &column_ids) { + return None; + } + // ONE law with the commit: the committed order is the canonical + // linearization of the moved sheet, so the preview shows exactly it. + canonicalize_sheet(&mut sheet); + Some(sheet) } /// Read-only preview with the dragged subtree lifted out (drag in flight, no From 56b8bb28a16b73c8ed1171ef702a5f35eb4084f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:47:20 +0000 Subject: [PATCH 18/24] =?UTF-8?q?flowstate-collab:=20protocol=20v4=20?= =?UTF-8?q?=E2=80=94=20DocumentKind=20in=20the=20ticket=20HMAC=20+=20flow?= =?UTF-8?q?=20I/O=20pump=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROTOCOL_VERSION 3→4. SessionTicket carries document: DocumentKind {RichText, Flow} INSIDE the HMAC tag (kind-flipping is an authentication failure, not a silent reroute), with leading-varint version pre-decode so cross-version tickets yield actionable UX messages ('outdated invite' / 'created by a newer Flowstate') instead of generic decode failures. The kind threads through standing-access grants and discovery admission grants into minted tickets. flow_io_pump mirrors the doc_io_pump drip suite: held-gate coalescing bounds, duplicated/reversed delivery, deferred non-import ordering, exactly-once publish pump, and .fl0 save/recovery round trips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-collab/src/flow/runtime.rs | 10 + crates/flowstate-collab/src/net/direct.rs | 9 +- crates/flowstate-collab/src/net/mod.rs | 1 + crates/flowstate-collab/src/net/runtime.rs | 4 +- crates/flowstate-collab/src/proto_direct.rs | 3 + crates/flowstate-collab/src/proto_gossip.rs | 9 +- crates/flowstate-collab/src/ticket.rs | 74 +++++- crates/flowstate-collab/tests/flow_io_pump.rs | 226 ++++++++++++++++++ .../flowstate-collab/tests/swarm_loopback.rs | 2 +- crates/flowstate/src/collab/manager.rs | 12 +- crates/flowstate/src/collab/session.rs | 7 + 11 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 crates/flowstate-collab/tests/flow_io_pump.rs diff --git a/crates/flowstate-collab/src/flow/runtime.rs b/crates/flowstate-collab/src/flow/runtime.rs index 7c4141a1..7479332b 100644 --- a/crates/flowstate-collab/src/flow/runtime.rs +++ b/crates/flowstate-collab/src/flow/runtime.rs @@ -138,6 +138,9 @@ pub struct FlowRuntime { pub(crate) defects: Vec, /// Remaining import-side order-canonicalization repair passes (loop guard). order_repairs_remaining: u32, + /// Batched-import calls served (one per gate hold) — the §6.4 coalescing + /// counter the pump tests assert against. + import_batches_served: u64, } impl FlowRuntime { @@ -190,6 +193,7 @@ impl FlowRuntime { undo_meta, defects, order_repairs_remaining: 64, + import_batches_served: 0, }) } @@ -422,6 +426,7 @@ impl FlowRuntime { if chunks.is_empty() { return Ok(()); } + self.import_batches_served += 1; let frontier_before = self.doc.state_frontiers(); for chunk in chunks { self.doc.import(chunk).context("importing remote flow update")?; @@ -509,6 +514,11 @@ impl FlowRuntime { pub fn can_redo(&self) -> bool { self.undo.can_redo() } + + #[must_use] + pub fn import_batches_served(&self) -> u64 { + self.import_batches_served + } } /// Debug-audit diagnostics: the first field-level difference between the diff --git a/crates/flowstate-collab/src/net/direct.rs b/crates/flowstate-collab/src/net/direct.rs index 924ad585..9126d922 100644 --- a/crates/flowstate-collab/src/net/direct.rs +++ b/crates/flowstate-collab/src/net/direct.rs @@ -83,6 +83,7 @@ pub struct DirectServeState { struct StandingAccessGrant { document_fingerprint: [u8; 32], title: String, + document: crate::ticket::DocumentKind, identities: HashSet, } @@ -130,6 +131,7 @@ impl DirectServeState { session: SessionId, document_fingerprint: [u8; 32], title: String, + document: crate::ticket::DocumentKind, identities: HashSet, ) { self.standing_access.write().await.insert( @@ -137,6 +139,7 @@ impl DirectServeState { StandingAccessGrant { document_fingerprint, title, + document, identities, }, ); @@ -190,15 +193,15 @@ impl DirectServeState { .get(&session) .and_then(|grant| { (grant.document_fingerprint == request.document_fingerprint && grant.identities.contains(&request.identity)) - .then(|| grant.title.clone()) + .then(|| (grant.title.clone(), grant.document)) }); - let Some(title) = title else { + let Some((title, document)) = title else { return ServeOutcome::Header(DirectResponseHeader::Unauthorized); }; let Some(admission) = self.auth.admission(session) else { return ServeOutcome::Header(DirectResponseHeader::NotAttached); }; - let payload = match postcard::to_stdvec(&DiscoveryAdmissionGrant { admission, title }) { + let payload = match postcard::to_stdvec(&DiscoveryAdmissionGrant { admission, title, document }) { Ok(payload) => payload, Err(_) => return ServeOutcome::Header(DirectResponseHeader::NotFound), }; diff --git a/crates/flowstate-collab/src/net/mod.rs b/crates/flowstate-collab/src/net/mod.rs index 93ed6562..853ab0cb 100644 --- a/crates/flowstate-collab/src/net/mod.rs +++ b/crates/flowstate-collab/src/net/mod.rs @@ -56,6 +56,7 @@ pub enum NetCommand { handler: DirectSessionHandler, }, ConfigureStandingAccess { + document: crate::ticket::DocumentKind, session: SessionId, document_fingerprint: [u8; 32], title: String, diff --git a/crates/flowstate-collab/src/net/runtime.rs b/crates/flowstate-collab/src/net/runtime.rs index 95bdd7c8..ec1b373f 100644 --- a/crates/flowstate-collab/src/net/runtime.rs +++ b/crates/flowstate-collab/src/net/runtime.rs @@ -144,13 +144,14 @@ async fn net_main(cmd_rx: async_channel::Receiver, evt_tx: async_cha direct_state.register_handler(session, handler).await; }, NetCommand::ConfigureStandingAccess { + document, session, document_fingerprint, title, identities, } => { direct_state - .configure_standing_access(session, document_fingerprint, title, identities.into_iter().collect()) + .configure_standing_access(session, document_fingerprint, title, document, identities.into_iter().collect()) .await; }, NetCommand::RequestDiscoveredTicket { @@ -180,6 +181,7 @@ async fn net_main(cmd_rx: async_channel::Receiver, evt_tx: async_cha vec![advertisement.endpoint], grant.title, grant.admission, + grant.document, )) } .await; diff --git a/crates/flowstate-collab/src/proto_direct.rs b/crates/flowstate-collab/src/proto_direct.rs index 7e2ee81a..65ce3cde 100644 --- a/crates/flowstate-collab/src/proto_direct.rs +++ b/crates/flowstate-collab/src/proto_direct.rs @@ -51,6 +51,9 @@ pub enum DirectRequest { pub struct DiscoveryAdmissionGrant { pub admission: SessionAdmission, pub title: String, + /// The session's document kind (v4) — carried into the minted ticket. + #[serde(default)] + pub document: crate::ticket::DocumentKind, } /// How a streamed direct payload is encoded on the wire. Rides on the response diff --git a/crates/flowstate-collab/src/proto_gossip.rs b/crates/flowstate-collab/src/proto_gossip.rs index 98ebfee8..590f5bad 100644 --- a/crates/flowstate-collab/src/proto_gossip.rs +++ b/crates/flowstate-collab/src/proto_gossip.rs @@ -5,9 +5,12 @@ use serde::{Deserialize, Serialize}; use crate::ids::{BlobId, SessionId}; -/// Version 3 uses symmetric, session-lifetime editor admission. Pre-release -/// protocol versions are intentionally not accepted. -pub const PROTOCOL_VERSION: u16 = 3; +/// Version 4 adds the session DOCUMENT KIND (rich text vs flow) to the +/// invite ticket — HMAC-covered, so a ticket's kind cannot be flipped — and +/// flow-aware presence focus. Version 3 used symmetric, session-lifetime +/// editor admission; pre-release protocol versions are intentionally not +/// accepted. +pub const PROTOCOL_VERSION: u16 = 4; pub const GOSSIP_INLINE_LIMIT: usize = 2 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/flowstate-collab/src/ticket.rs b/crates/flowstate-collab/src/ticket.rs index ebd874a4..303dbf1b 100644 --- a/crates/flowstate-collab/src/ticket.rs +++ b/crates/flowstate-collab/src/ticket.rs @@ -8,7 +8,18 @@ use crate::{SessionId, admission::SessionAdmission, proto_gossip::PROTOCOL_VERSI pub const TICKET_KIND: &str = "fscollab"; pub const INVITE_URL_PREFIX: &str = "flowstate://join#"; -/// Symmetric editor invite for one ephemeral live session (wire version 3). +/// What kind of document a live session shares. Rides the invite ticket +/// UNDER the HMAC tag (wire v4), so a ticket's kind cannot be flipped in +/// transit — a joiner spins up the right runtime before the first byte of +/// snapshot arrives (snapshot schema sniffing stays as defense-in-depth). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum DocumentKind { + #[default] + RichText, + Flow, +} + +/// Symmetric editor invite for one ephemeral live session (wire version 4). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SessionTicket { pub version: u16, @@ -16,18 +27,20 @@ pub struct SessionTicket { pub bootstrap: Vec, pub title: String, pub admission: SessionAdmission, + pub document: DocumentKind, tag: [u8; 32], } impl SessionTicket { #[must_use] - pub fn new(session: SessionId, bootstrap: Vec, title: String, admission: SessionAdmission) -> Self { + pub fn new(session: SessionId, bootstrap: Vec, title: String, admission: SessionAdmission, document: DocumentKind) -> Self { let mut ticket = Self { version: PROTOCOL_VERSION, session, bootstrap, title, admission, + document, tag: [0; 32], }; ticket.tag = ticket @@ -41,17 +54,33 @@ impl SessionTicket { self.version == PROTOCOL_VERSION } + /// Version-mismatch UX: a specific, actionable message per direction + /// instead of a generic failure. + fn version_mismatch_message(version: u16) -> Option<&'static str> { + if version < PROTOCOL_VERSION { + Some("this collaboration invite is outdated and can no longer be joined — ask for a fresh invite") + } else if version > PROTOCOL_VERSION { + Some("this collaboration invite was created by a newer Flowstate — update Flowstate to join") + } else { + None + } + } + /// Authenticate all user-visible and routing metadata before any dial. pub fn verify_for_join(&self) -> Result<()> { - ensure!(self.is_supported_version(), "unsupported collaboration protocol version"); + if let Some(message) = Self::version_mismatch_message(self.version) { + anyhow::bail!(message); + } ensure!(!self.bootstrap.is_empty(), "collaboration invite has no reachable participants"); ensure!(self.tag == self.expected_tag()?, "collaboration invite metadata is invalid"); Ok(()) } fn expected_tag(&self) -> Result<[u8; 32]> { - let payload = - postcard::to_stdvec(&(self.version, self.session, &self.bootstrap, &self.title)).context("encoding collaboration invite metadata")?; + // The document KIND is tag-covered (v4): flipping it would route a joiner + // into the wrong runtime kind. + let payload = postcard::to_stdvec(&(self.version, self.session, &self.bootstrap, &self.title, self.document)) + .context("encoding collaboration invite metadata")?; Ok(self.admission.tag(&payload)) } @@ -82,6 +111,14 @@ impl Ticket for SessionTicket { } fn decode_bytes(bytes: &[u8]) -> Result { + // Leading-varint pre-decode: the version is the first field, so a ticket + // from a DIFFERENT wire version (whose full struct layout won't parse) + // still yields the right UX message instead of a generic decode failure. + if let Ok((version, _)) = postcard::take_from_bytes::(bytes) + && let Some(message) = Self::version_mismatch_message(version) + { + return Err(ParseError::verification_failed(message)); + } let ticket: Self = postcard::from_bytes(bytes).map_err(|_| ParseError::verification_failed("invalid collaboration ticket payload"))?; ticket .verify_for_join() @@ -103,6 +140,7 @@ mod tests { vec![EndpointAddr::new(SecretKey::from_bytes(&[2; 32]).public())], "Shared brief".to_string(), SessionAdmission::from_bytes([3; 32]), + super::DocumentKind::Flow, ) } @@ -139,6 +177,32 @@ mod tests { assert!(ticket.verify_for_join().is_err()); } + #[test] + fn document_kind_round_trips_and_is_tag_covered() { + let encoded = ticket().encode_text(); + let decoded = SessionTicket::decode_text(&encoded).expect("decode"); + assert_eq!(decoded.document, super::DocumentKind::Flow); + // Kind-flipping is an HMAC failure, not a silent reroute. + let mut flipped = ticket(); + flipped.document = super::DocumentKind::RichText; + assert!(flipped.verify_for_join().is_err()); + } + + #[test] + fn version_mismatch_yields_actionable_messages() { + use iroh_tickets::Ticket as _; + let mut old = ticket(); + old.version = 3; + let bytes = old.encode_bytes(); + let error = SessionTicket::decode_bytes(&bytes).expect_err("v3 must be rejected"); + assert!(error.to_string().contains("outdated"), "{error}"); + let mut newer = ticket(); + newer.version = 99; + let bytes = newer.encode_bytes(); + let error = SessionTicket::decode_bytes(&bytes).expect_err("future version must be rejected"); + assert!(error.to_string().contains("newer Flowstate"), "{error}"); + } + #[test] fn any_participant_endpoint_can_be_the_bootstrap() { let mut ticket = ticket(); diff --git a/crates/flowstate-collab/tests/flow_io_pump.rs b/crates/flowstate-collab/tests/flow_io_pump.rs new file mode 100644 index 00000000..be78b6de --- /dev/null +++ b/crates/flowstate-collab/tests/flow_io_pump.rs @@ -0,0 +1,226 @@ +//! The flow I/O pump harness — the `doc_io_pump` drip suite mirrored onto +//! `FlowIoHandle` (build-order step 6): §6.4 import coalescing behind a held +//! gate, pathological delivery order, deferred non-import ordering, the +//! exactly-once publish pump, and the `.fl0` save/recovery byte paths. + +use std::sync::Arc; + +use anyhow::Result; +use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}; +use flowstate_collab::local_write::{GateHolder, WriteGate}; +use flowstate_flow::format::{FlowFormat, SheetId}; +use flowstate_flow::intents::FlowIntent; +use uuid::Uuid; + +type FlowGate = Arc>; + +/// A based (source handle, receiver gate) pair plus `edits` sheet renames +/// exported as one delta blob PER EDIT (the network drip shape) and the +/// source's final board. +fn drip_pair(edits: usize) -> Result<(flowstate_flow::projection::FlowBoardProjection, Vec>, FlowGate)> { + let format = FlowFormat::policy_debate(); + let (source_handle, source_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let sheet: SheetId = Uuid::new_v4(); + source_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Drip".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("seed sheet rejected: {error}"))?; + let snapshot = source_handle.with_test_runtime(|runtime| runtime.snapshot())?; + let (_receiver_handle, receiver_gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); + + let mut blobs = Vec::with_capacity(edits); + for edit_ix in 0..edits { + let vv_before = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .oplog_version_vector(); + source_handle + .apply(FlowIntent::RenameSheet { + sheet_id: sheet, + name: format!("Drip {edit_ix}"), + }) + .map_err(|error| anyhow::anyhow!("drip edit {edit_ix} rejected: {error}"))?; + let blob = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .export_updates_for(&vv_before)?; + blobs.push(blob); + } + let final_board = source_handle + .board_projection() + .map_err(|error| anyhow::anyhow!("board: {error}"))?; + Ok((final_board, blobs, receiver_gate)) +} + +fn board(gate: &FlowGate) -> flowstate_flow::projection::FlowBoardProjection { + gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .board_ref() + .clone() +} + +fn import_batches(gate: &FlowGate) -> u64 { + gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .import_batches_served() +} + +/// A 40-blob drip fired while the gate is HELD must coalesce into a few +/// batched imports, every reply must arrive, and the receiver must converge. +#[test] +fn held_gate_drip_coalesces_into_bounded_batches() -> Result<()> { + const EDITS: usize = 40; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let batches_before = import_batches(&gate); + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + #[allow(clippy::needless_collect, reason = "spawn-all-then-join-all; lazy iteration would serialize the burst")] + let senders: Vec<_> = blobs + .iter() + .map(|blob| { + let io_import = io.clone(); + let blob = blob.clone(); + std::thread::spawn(move || pollster::block_on(io_import.import_remote_update(blob))) + }) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(hold); + let replies: Vec<_> = senders + .into_iter() + .map(|sender| sender.join().expect("sender join")) + .collect(); + + assert_eq!(replies.len(), EDITS); + for (blob_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "blob {blob_ix} reply failed: {reply:?}"); + } + assert_eq!(board(&gate), source_board, "receiver did not converge to the source board"); + let batches = import_batches(&gate) - batches_before; + assert!( + (3..=8).contains(&batches), + "expected 3..=8 coalesced batches for {EDITS} blobs behind a held gate, saw {batches}" + ); + Ok(()) +} + +/// Duplicated + reversed blobs: Loro buffers out-of-order updates and +/// re-applies duplicates as no-ops — every reply Ok, receiver converges. +#[test] +fn duplicated_and_reversed_drip_converges() -> Result<()> { + const EDITS: usize = 12; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let mut schedule: Vec> = Vec::new(); + for blob in blobs.iter().rev() { + schedule.push(blob.clone()); + schedule.push(blob.clone()); + } + let replies = pollster::block_on(async { + let mut replies = Vec::new(); + for blob in schedule { + replies.push(io.import_remote_update(blob).await); + } + replies + }); + for (delivery_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "delivery {delivery_ix} reply failed: {reply:?}"); + } + assert_eq!(board(&gate), source_board, "receiver did not converge under duplicated reversed delivery"); + Ok(()) +} + +/// A non-import request interleaved mid-burst is DEFERRED, not dropped. +#[test] +fn non_import_request_interleaved_with_burst_still_replies() -> Result<()> { + const EDITS: usize = 10; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + let io_imports = io.clone(); + let import_thread = std::thread::spawn(move || { + pollster::block_on(async move { + let mut replies = Vec::new(); + for blob in blobs { + replies.push(io_imports.import_remote_update(blob).await); + } + replies + }) + }); + let io_snapshot = io.clone(); + let snapshot_thread = std::thread::spawn(move || pollster::block_on(io_snapshot.board_snapshot())); + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(hold); + + let replies = import_thread.join().expect("imports join"); + for reply in &replies { + assert!(reply.is_ok(), "import reply failed: {reply:?}"); + } + let snapshot = snapshot_thread.join().expect("snapshot join"); + assert!(snapshot.is_ok(), "deferred non-import request lost/errored: {snapshot:?}"); + assert_eq!(board(&gate), source_board); + Ok(()) +} + +/// The publish pump drains committed local events exactly once. +#[test] +fn pump_publish_drains_exactly_once() -> Result<()> { + let format = FlowFormat::policy_debate(); + let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let _ = pollster::block_on(io.pump_publish())?; + handle + .apply(FlowIntent::CreateSheet { + sheet_id: Uuid::new_v4(), + name: "Published".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("edit rejected: {error}"))?; + let first = pollster::block_on(io.pump_publish())?; + assert!( + first + .iter() + .any(|event| matches!(event, FlowPublishEvent::LocalUpdate { .. })), + "first pump must return the committed LocalUpdate events" + ); + let second = pollster::block_on(io.pump_publish())?; + assert!(second.is_empty(), "second pump must be empty — double-publish hazard"); + Ok(()) +} + +/// SaveTo writes a valid v2 .fl0; EncodeBytes yields recovery bytes decoding +/// to the identical board. +#[test] +fn save_and_recovery_bytes_round_trip() -> Result<()> { + let (source_board, _, gate) = drip_pair(1)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let dir = std::env::temp_dir().join(format!("flowstate-flow-io-{}-{}", std::process::id(), Uuid::new_v4())); + std::fs::create_dir_all(&dir)?; + let path = dir.join("saved.fl0"); + pollster::block_on(io.save_to(path.clone()))?; + let snapshot = flowstate_flow::read_fl0(&path)?; + let restored = FlowRuntime::from_snapshot(&snapshot)?; + // The receiver gate has NOT imported the drip blob here — compare against + // its own board, not the source's. + let saved_board = board(&gate); + assert_eq!(restored.board_ref(), &saved_board); + let _ = source_board; + + let recovery = pollster::block_on(io.encode_bytes())?; + let decoded = flowstate_flow::decode_fl0_snapshot(&recovery)?; + let recovered = FlowRuntime::from_snapshot(&decoded)?; + assert_eq!(recovered.board_ref(), &saved_board); + std::fs::remove_dir_all(&dir).ok(); + Ok(()) +} diff --git a/crates/flowstate-collab/tests/swarm_loopback.rs b/crates/flowstate-collab/tests/swarm_loopback.rs index 309e8504..e3c005b0 100644 --- a/crates/flowstate-collab/tests/swarm_loopback.rs +++ b/crates/flowstate-collab/tests/swarm_loopback.rs @@ -130,7 +130,7 @@ async fn swarm_loopback_net_subset() -> Result<()> { // owner proxy: C can authenticate directly to B and bootstrap from B even // if A is unavailable. install_snapshot_handler(&b, session, b"snapshot-from-b".to_vec()).await; - let b_invite = SessionTicket::new(session, vec![b_addr.clone()], "Shared brief".into(), admission.clone()); + let b_invite = SessionTicket::new(session, vec![b_addr.clone()], "Shared brief".into(), admission.clone(), flowstate_collab::ticket::DocumentKind::RichText); let b_invite = SessionTicket::decode_text(&b_invite.encode_text())?; let snapshot = direct::pull_with_endpoint( &c.endpoint, diff --git a/crates/flowstate/src/collab/manager.rs b/crates/flowstate/src/collab/manager.rs index e5960779..f2ffe0ec 100644 --- a/crates/flowstate/src/collab/manager.rs +++ b/crates/flowstate/src/collab/manager.rs @@ -309,6 +309,7 @@ impl CollabManager { let session_id = self.session_by_panel.get(&panel_id).copied()?; let session = self.sessions_by_id.get(&session_id)?.clone(); let title = session.read(cx).title().to_string(); + let document = session.read(cx).document_kind(); let commands = self.ensure_runtime(cx).ok()?; let (ticket_tx, ticket_rx) = async_channel::bounded(1); let (reply_tx, reply_rx) = async_channel::bounded(1); @@ -327,7 +328,7 @@ impl CollabManager { tracing::info!(%session_id, inviter = %minted.inviter.id, "created collaboration share ticket"); let own_endpoint_id = minted.inviter.id; let _ = cx.update_global::(|manager, _| manager.own_endpoint_id = Some(own_endpoint_id)); - Ok(SessionTicket::new(session_id, vec![minted.inviter], title, minted.admission)) + Ok(SessionTicket::new(session_id, vec![minted.inviter], title, minted.admission, document)) }, Ok(Err(error)) => { tracing::error!(%session_id, error = %format_args!("{error:#}"), "minting collaboration invite failed"); @@ -538,9 +539,18 @@ impl CollabManager { .cloned() .unwrap_or_else(|| "Shared document".into()); let identities = trusted_identity_keys_for_path(path); + let document = self + .sessions_by_id + .get(&session) + .map_or(flowstate_collab::ticket::DocumentKind::RichText, |_| { + // Step 9 (session enum split) threads the live session's kind here; + // until then every standing-access session is a rich-text document. + flowstate_collab::ticket::DocumentKind::RichText + }); if let Err(error) = runtime .commands .try_send(NetCommand::ConfigureStandingAccess { + document, session, document_fingerprint, title, diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index a8f03cb3..b1189b45 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -305,6 +305,13 @@ impl CollabSession { &self.title } + /// The session's document kind (rides the v4 invite ticket under its HMAC). + /// Step 9 (session enum split) derives this from the attached editor arm; + /// every current session is a rich-text document. + pub fn document_kind(&self) -> flowstate_collab::ticket::DocumentKind { + flowstate_collab::ticket::DocumentKind::RichText + } + pub(super) fn set_admission(&mut self, admission: SessionAdmission) { self.admission = Some(admission); } From 3a7c9dc4b73ef9448b2797b5a306c65902f7fe6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:50:05 +0000 Subject: [PATCH 19/24] flowstate-collab: SyncIoHandle + kind-agnostic direct serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sync_io.rs: SyncIoHandle { RichText(DocIoHandle), Flow(FlowIoHandle) } exposing the three transport-facing calls (oplog_version_vector / export_updates_for / snapshot_bytes) + as_* accessors. DirectSessionHandler.io retypes to Option (the only in-crate retype; new_rich_text keeps the pre-flow call shape). Flow sessions now serve direct snapshot/updates pulls straight off the flow I/O service — verified by a flow-variant loopback pull test alongside the untouched rich-text loopback regression. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-collab/src/lib.rs | 3 + crates/flowstate-collab/src/net/direct.rs | 18 ++-- crates/flowstate-collab/src/sync_io.rs | 67 ++++++++++++++ .../flowstate-collab/tests/swarm_loopback.rs | 87 +++++++++++++++++++ crates/flowstate/src/collab/session.rs | 2 +- 5 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 crates/flowstate-collab/src/sync_io.rs diff --git a/crates/flowstate-collab/src/lib.rs b/crates/flowstate-collab/src/lib.rs index 2f9d9f05..ff5f1f7a 100644 --- a/crates/flowstate-collab/src/lib.rs +++ b/crates/flowstate-collab/src/lib.rs @@ -20,9 +20,12 @@ pub mod presence; pub mod proto_direct; pub mod proto_gossip; pub mod self_check; +pub mod sync_io; pub mod ticket; pub use admission::SessionAdmission; pub use ids::{BlobId, SessionId}; pub use proto_direct::DIRECT_ALPN; pub use proto_gossip::PROTOCOL_VERSION; +pub use sync_io::SyncIoHandle; +pub use ticket::DocumentKind; diff --git a/crates/flowstate-collab/src/net/direct.rs b/crates/flowstate-collab/src/net/direct.rs index 9126d922..5b342d71 100644 --- a/crates/flowstate-collab/src/net/direct.rs +++ b/crates/flowstate-collab/src/net/direct.rs @@ -19,6 +19,7 @@ use tokio::{ use crate::{ admission::SessionAdmission, doc_io::DocIoHandle, + sync_io::SyncIoHandle, ids::{BlobId, SessionId}, proto_direct::{ AssetBytes, DIRECT_ALPN, DirectRequest, DirectResponseHeader, DiscoveryAdmissionGrant, MAX_FRAME_LEN, MAX_PAYLOAD_CHUNK_LEN, @@ -46,15 +47,22 @@ pub struct DirectSessionHandler { /// mid-intent state, so the old shared read-handle path is outlawed), and /// snapshot exports fork under the gate + export off it, so a peer's /// recovery pull still cannot be starved behind local edits. `None` falls - /// back to the session-served request channel. - io: Option, + /// back to the session-served request channel. Kind-agnostic (spec Part C): + /// rich-text and flow sessions serve the identical three transport calls. + io: Option, } impl DirectSessionHandler { #[must_use] - pub fn new(requests: Sender, io: Option) -> Self { + pub fn new(requests: Sender, io: Option) -> Self { Self { requests, io } } + + /// Rich-text convenience constructor (the pre-flow call shape). + #[must_use] + pub fn new_rich_text(requests: Sender, io: Option) -> Self { + Self::new(requests, io.map(SyncIoHandle::RichText)) + } } impl std::fmt::Debug for DirectSessionHandler { @@ -548,7 +556,7 @@ where /// is a brief `fork()`; the actual snapshot encode happens off-gate on the I/O /// thread (spec I-9a long-export rule), so a large snapshot neither stalls /// typing nor gets starved behind local edits. -async fn serve_snapshot_via_io(session: SessionId, io: DocIoHandle) -> ServeOutcome { +async fn serve_snapshot_via_io(session: SessionId, io: SyncIoHandle) -> ServeOutcome { match io.snapshot_bytes().await { Ok(bytes) => { tracing::trace!(%session, bytes = bytes.len(), "served collaboration snapshot via the document I/O service"); @@ -564,7 +572,7 @@ async fn serve_snapshot_via_io(session: SessionId, io: DocIoHandle) -> ServeOutc /// Serve an incremental-updates pull through the document I/O service (see /// [`serve_snapshot_via_io`]); the version-vector decode and gate-held export /// happen on the I/O thread. -async fn serve_updates_via_io(session: SessionId, io: DocIoHandle, have_vv: Vec) -> ServeOutcome { +async fn serve_updates_via_io(session: SessionId, io: SyncIoHandle, have_vv: Vec) -> ServeOutcome { match io.export_updates_for(have_vv).await { Ok(bytes) => { tracing::trace!(%session, bytes = bytes.len(), "served collaboration updates via the document I/O service"); diff --git a/crates/flowstate-collab/src/sync_io.rs b/crates/flowstate-collab/src/sync_io.rs new file mode 100644 index 00000000..7d302382 --- /dev/null +++ b/crates/flowstate-collab/src/sync_io.rs @@ -0,0 +1,67 @@ +//! `SyncIoHandle` — the kind-agnostic face of a session's document I/O +//! service (flow spec Part C). Transport code (direct pulls, anti-entropy) +//! needs exactly three calls — oplog version vector, incremental update +//! export, snapshot export — and bytes are bytes: the two runtimes differ +//! only in which gate the I/O thread answers under. + +use anyhow::Result; + +use crate::doc_io::DocIoHandle; +use crate::flow::FlowIoHandle; + +#[derive(Clone)] +pub enum SyncIoHandle { + RichText(DocIoHandle), + Flow(FlowIoHandle), +} + +impl SyncIoHandle { + pub async fn oplog_version_vector(&self) -> Result> { + match self { + Self::RichText(io) => io.oplog_version_vector().await, + Self::Flow(io) => io.oplog_version_vector().await, + } + } + + pub async fn export_updates_for(&self, remote_vv: Vec) -> Result> { + match self { + Self::RichText(io) => io.export_updates_for(remote_vv).await, + Self::Flow(io) => io.export_updates_for(remote_vv).await, + } + } + + pub async fn snapshot_bytes(&self) -> Result> { + match self { + Self::RichText(io) => io.snapshot_bytes().await, + Self::Flow(io) => io.snapshot_bytes().await, + } + } + + #[must_use] + pub fn as_rich_text(&self) -> Option<&DocIoHandle> { + match self { + Self::RichText(io) => Some(io), + Self::Flow(_) => None, + } + } + + #[must_use] + pub fn as_flow(&self) -> Option<&FlowIoHandle> { + match self { + Self::Flow(io) => Some(io), + Self::RichText(_) => None, + } + } +} + +impl From for SyncIoHandle { + fn from(io: DocIoHandle) -> Self { + Self::RichText(io) + } +} + +impl From for SyncIoHandle { + fn from(io: FlowIoHandle) -> Self { + Self::Flow(io) + } +} diff --git a/crates/flowstate-collab/tests/swarm_loopback.rs b/crates/flowstate-collab/tests/swarm_loopback.rs index e3c005b0..254ae36e 100644 --- a/crates/flowstate-collab/tests/swarm_loopback.rs +++ b/crates/flowstate-collab/tests/swarm_loopback.rs @@ -209,6 +209,93 @@ async fn swarm_loopback_net_subset() -> Result<()> { Ok(()) } +/// Flow-variant direct serving (flow spec Part C): a session whose handler +/// carries `SyncIoHandle::Flow` serves snapshot/updates pulls straight off the +/// flow I/O service — the identical transport surface the rich-text arm uses. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires local UDP loopback and real iroh gossip timing"] +async fn flow_direct_snapshot_and_updates_pull() -> Result<()> { + use flowstate_collab::SyncIoHandle; + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowRuntime}; + use flowstate_flow::intents::FlowIntent; + + let session = SessionId::new(); + let lookup = MemoryLookup::new(); + let a = Peer::spawn(lookup.clone()).await?; + let b = Peer::spawn(lookup.clone()).await?; + let admission = SessionAdmission::generate(); + for peer in [&a, &b] { + peer + .direct_state + .auth() + .configure_session(session, admission.clone()); + } + auth::install_local_admission(session, admission.clone()); + let a_addr = wait_addr(&a.endpoint).await; + let b_addr = wait_addr(&b.endpoint).await; + for addr in [a_addr.clone(), b_addr.clone()] { + lookup.add_endpoint_info(addr); + } + + // A hosts a real flow runtime behind its flow I/O service. + let format = flowstate_flow::FlowFormat::policy_debate(); + let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("flow runtime")); + let sheet = uuid::Uuid::new_v4(); + handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Served".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("sheet"); + let expected_board = handle.board_projection().expect("board"); + let vv_at_snapshot = handle.with_test_runtime(|runtime| runtime.oplog_version_vector()); + let flow_io = FlowIoHandle::spawn(std::sync::Arc::clone(&gate)).expect("flow io"); + let (request_tx, _request_rx) = async_channel::unbounded(); + a.direct_state + .register_handler(session, DirectSessionHandler::new(request_tx, Some(SyncIoHandle::Flow(flow_io)))) + .await; + + // B pulls the snapshot and reconstructs the identical board. + let snapshot = direct::pull_with_endpoint( + &b.endpoint, + DirectRequest::Snapshot { session }, + vec![a_addr.id], + Duration::from_secs(5), + ) + .await?; + let joined = FlowRuntime::from_snapshot(&snapshot).expect("joiner runtime"); + assert_eq!(joined.board_ref(), &expected_board); + + // A commits more; B pulls incremental updates for its version vector. + handle + .apply(FlowIntent::RenameSheet { + sheet_id: sheet, + name: "Renamed after snapshot".into(), + }) + .expect("rename"); + let updates = direct::pull_with_endpoint( + &b.endpoint, + DirectRequest::Updates { + session, + have_vv: vv_at_snapshot.encode(), + }, + vec![a_addr.id], + Duration::from_secs(5), + ) + .await?; + ensure!(!updates.is_empty(), "updates pull returned no bytes"); + let mut joined = joined; + joined.import_remote_updates(&[updates.as_slice()])?; + assert_eq!( + joined.board_ref().sheet(sheet).map(|sheet| sheet.name.as_str()), + Some("Renamed after snapshot") + ); + a.router.shutdown().await.ok(); + b.router.shutdown().await.ok(); + Ok(()) +} + async fn install_snapshot_handler(peer: &Peer, session: SessionId, snapshot: Vec) { let (request_tx, request_rx) = async_channel::unbounded(); peer diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index b1189b45..1ba3a411 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -342,7 +342,7 @@ impl CollabSession { // thread (spec I-9a: raw ungated doc reads are outlawed — they can // force-commit mid-intent state). Absent a runtime yet, it falls back to // the session-served request channel. - DirectSessionHandler::new(self.direct_tx.clone(), self.runtime.clone()) + DirectSessionHandler::new_rich_text(self.direct_tx.clone(), self.runtime.clone()) } pub(super) fn pull_candidates(&self, preferred: Option) -> Vec { From c371ef75256293bbfa7fc2212df60cf81bad1839 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:09:15 +0000 Subject: [PATCH 20/24] flowstate: FlowEditor rewired onto the gated flow authority (solo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flowstate-flow drops the record-era API entirely (FlowDocument/ FlowProjection/entity deltas/v1 persistence deleted; lib.rs is v2-only). FlowEditor holds Arc + a drained FlowBoardProjection copy; all ~19 op call sites route through handle.apply(FlowIntent::…) (adds collapse into one CellPlacement path), drag commits through MoveCellSubtree with preview == commit via board_ops, undo/redo ride the whole-doc stack with FlowUndoMeta focus restore, and saves fork a v2 .fl0 snapshot. cell_editing.rs's observer/re-encode loop is DELETED — each open cell's RichTextEditor is driven by handle.cell_authority(id) through set_write_authority, and the flow editor only listens for Changed events to refresh its board copy. Inactive cells render from a materialize-on- miss preview cache invalidated by board stream items. create_flow_panel takes a FlowRuntimeSource, spawns the panel's FlowIoHandle into the new Workspace::flow_document_runtimes map; loads go read_fl0 → FlowRuntime::from_snapshot. Gate: headless flow edit/save/undo through the authority (sheet+cell intents, cell typing via FlowCellAuthority, MoveCellSubtree drag commit, undo reversal, .fl0 v2 save round trip) + full app lib suite green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-collab/src/flow/commit.rs | 10 - crates/flowstate-collab/src/flow/handle.rs | 24 + crates/flowstate-flow/src/collaboration.rs | 55 - crates/flowstate-flow/src/document.rs | 829 ------------ crates/flowstate-flow/src/lib.rs | 30 +- crates/flowstate-flow/src/operations.rs | 1167 ----------------- crates/flowstate-flow/src/persistence.rs | 93 +- crates/flowstate/src/flow/editor.rs | 485 ++++--- .../flowstate/src/flow/editor/annotation.rs | 44 +- .../flowstate/src/flow/editor/cell_editing.rs | 142 +- crates/flowstate/src/flow/editor/drag_drop.rs | 44 +- crates/flowstate/src/flow/editor/layout.rs | 11 +- crates/flowstate/src/flow/editor/telemetry.rs | 33 +- crates/flowstate/src/flow/ribbon.rs | 14 +- .../src/workspace/workspace/documents.rs | 54 +- .../headless_tests/flows_and_keystrokes.rs | 101 ++ .../flowstate/src/workspace/workspace/mod.rs | 1 + .../src/workspace/workspace/render_outline.rs | 10 +- 18 files changed, 563 insertions(+), 2584 deletions(-) delete mode 100644 crates/flowstate-flow/src/collaboration.rs delete mode 100644 crates/flowstate-flow/src/document.rs delete mode 100644 crates/flowstate-flow/src/operations.rs diff --git a/crates/flowstate-collab/src/flow/commit.rs b/crates/flowstate-collab/src/flow/commit.rs index e16ce008..ccdae311 100644 --- a/crates/flowstate-collab/src/flow/commit.rs +++ b/crates/flowstate-collab/src/flow/commit.rs @@ -71,14 +71,6 @@ pub(crate) fn apply_flow_intent(core: &mut FlowRuntime, intent: &FlowIntent) -> } selection_after = caret.and_then(|caret| cell_selection_snapshot(&doc, cell, caret)); }, - Derived::BoardAndContent { cell } => { - // Structural change already applied to the maintained board; fold in the - // (possibly fresh) cell summary, then one board Replace. - let _ = core - .refresh_cell(cell) - .map_err(|error| compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error))?; - core.push_board_stream(); - }, } // ---- Phase 5: publish ------------------------------------------------------ @@ -101,8 +93,6 @@ enum Derived { Board, /// One cell's rich text changed (cell rematerialization + summary check). Content { cell: CellId, caret: Option }, - /// Structural board change PLUS one cell's content (AddCell). - BoardAndContent { cell: CellId }, } enum ExecuteError { diff --git a/crates/flowstate-collab/src/flow/handle.rs b/crates/flowstate-collab/src/flow/handle.rs index cf3773bd..fb10c58e 100644 --- a/crates/flowstate-collab/src/flow/handle.rs +++ b/crates/flowstate-collab/src/flow/handle.rs @@ -153,6 +153,30 @@ impl FlowDocHandle { Ok(self.lock(GateHolder::UndoRedo)?.can_redo()) } + /// Read-only cell materialization for render previews — unlike + /// [`Self::open_cell`], no stream/cache entry is created. + pub fn cell_preview(&self, cell: CellId) -> Result { + self.with_runtime(GateHolder::LocalIntent, |runtime| { + flowstate_flow::loro_projection::materialize_cell_projection( + runtime.doc(), + cell, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + })? + } + + /// Whole-doc Loro snapshot under a brief gate hold (solo save path; the + /// collaboration save goes through the flow I/O service's fork-off-gate). + pub fn snapshot(&self) -> Result, FlowWriteRejected> { + self + .with_runtime(GateHolder::DocumentService, |runtime| { + runtime + .snapshot() + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + })? + } + /// Test/tooling support: run a closure against the gate-held runtime (the /// flow analog of holding a test gate guard on the .db8 core). pub fn with_test_runtime(&self, call: impl FnOnce(&mut FlowRuntime) -> T) -> T { diff --git a/crates/flowstate-flow/src/collaboration.rs b/crates/flowstate-flow/src/collaboration.rs deleted file mode 100644 index f740322c..00000000 --- a/crates/flowstate-flow/src/collaboration.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::fmt; - -use loro::VersionVector; - -use crate::FlowProjection; - -pub type FlowFrontier = Vec; -pub type FlowUpdateBytes = Vec; -pub type FlowTransactionId = u128; - -#[derive(Clone, Debug)] -pub struct FlowProjectionSnapshot { - pub projection: FlowProjection, - pub frontier: FlowFrontier, - pub version_vector: VersionVector, -} - -#[derive(Clone, Debug)] -pub enum FlowRuntimeEvent { - LocalUpdate { - bytes: FlowUpdateBytes, - frontier: FlowFrontier, - version_vector: VersionVector, - }, - RemoteUpdateApplied { - bytes_len: usize, - frontier: FlowFrontier, - version_vector: VersionVector, - }, - ProjectionUpdated { - snapshot: Box, - }, -} - -#[derive(Clone, Debug)] -pub struct FlowCommitResult { - pub transaction_id: FlowTransactionId, - pub base_frontier: FlowFrontier, - pub new_frontier: FlowFrontier, - pub events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StaleFlowProjectionError { - pub expected: FlowFrontier, - pub actual: FlowFrontier, -} - -impl fmt::Display for StaleFlowProjectionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("flow collaboration transaction was based on a stale projection frontier") - } -} - -impl std::error::Error for StaleFlowProjectionError {} diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs deleted file mode 100644 index fe454504..00000000 --- a/crates/flowstate-flow/src/document.rs +++ /dev/null @@ -1,829 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use anyhow::{Context as _, bail}; -use loro::{ExportMode, LoroDoc, LoroValue, UndoManager, ValueOrContainer, VersionVector}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, -}; -pub use crate::format::{ - AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, CellId, ColumnDefinition, ColumnId, FlowFormat, FormatId, - SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, -}; - -const FORMAT_KEY: &str = "immutable-format"; -const ANNOTATIONS_MAP: &str = "flow-annotations"; -const SHEETS_MAP: &str = "flow-sheets"; -const CELLS_MAP: &str = "flow-cells"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Cell { - pub id: CellId, - pub column_id: ColumnId, - pub parent_id: Option, - pub document_bytes: Vec, -} - -impl Cell { - pub fn plain(column_id: ColumnId) -> anyhow::Result { - let document = flowstate_document::document_from_input( - flowstate_document::flowstate_document_theme(), - vec![flowstate_document::InputParagraph { - style: flowstate_document::PARAGRAPH_TAG, - runs: vec![flowstate_document::InputRun { - text: String::new(), - styles: flowstate_document::RunStyles::default(), - }], - }], - ); - Ok(Self { - id: Uuid::new_v4(), - column_id, - parent_id: None, - document_bytes: db8_bytes(&document)?, - }) - } - - pub fn document(&self) -> std::io::Result { - flowstate_document::read_db8_bytes(&self.document_bytes) - } - - pub fn is_empty(&self) -> std::io::Result { - let document = self.document()?; - Ok(document.text.to_string().trim().is_empty() && document.blocks.len() == document.paragraphs.len()) - } - - pub fn summary_text(&self) -> std::io::Result { - let document = self.document()?; - let mut projection = Vec::new(); - for (index, paragraph) in document.paragraphs.iter().enumerate() { - if matches!( - paragraph.style, - flowstate_document::PARAGRAPH_TAG | flowstate_document::PARAGRAPH_UNDERTAG | flowstate_document::PARAGRAPH_ANALYTIC - ) { - projection.push(paragraph_text(&document, index)); - continue; - } - - let text = paragraph_text(&document, index); - let mut cite_text = String::new(); - let mut offset = 0; - for run in ¶graph.runs { - let end = offset + run.len; - if run.styles.semantic == flowstate_document::SEMANTIC_CITE { - cite_text.push_str(&text[offset..end]); - } - offset = end; - } - if !cite_text.is_empty() { - projection.push(cite_text); - } - } - if !projection.is_empty() { - return Ok(projection.join("\n")); - } - Ok(document.text.to_string()) - } - - pub fn uses_summary_projection(&self) -> std::io::Result { - let document = self.document()?; - Ok(document.paragraphs.iter().any(|paragraph| { - matches!( - paragraph.style, - flowstate_document::PARAGRAPH_TAG | flowstate_document::PARAGRAPH_UNDERTAG | flowstate_document::PARAGRAPH_ANALYTIC - ) || paragraph - .runs - .iter() - .any(|run| run.styles.semantic == flowstate_document::SEMANTIC_CITE) - })) - } -} - -/// Encode a cell's rich-text projection as .db8 bytes. Post-Loro-cutover the -/// package writer is keyed off a `LoroDoc`, so round-trip through an import. -pub fn db8_bytes(document: &flowstate_document::DocumentProjection) -> std::io::Result> { - let doc = flowstate_document::document_to_loro(document, "Flow cell")?; - flowstate_document::loro_db8_bytes(&doc, "Flow cell") -} - -fn paragraph_text(document: &flowstate_document::DocumentProjection, index: usize) -> String { - document - .text - .byte_slice(flowstate_document::paragraph_byte_range(document, index)) - .to_string() -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Sheet { - pub id: SheetId, - pub name: String, - pub sheet_type_id: SheetTypeId, - pub cells: Vec, - pub annotations: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct SheetRecord { - id: SheetId, - name: String, - sheet_type_id: SheetTypeId, - order: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct CellRecord { - sheet_id: SheetId, - cell: Cell, - order: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct FlowProjection { - pub format: FlowFormat, - pub sheets: Vec, -} - -impl Default for FlowProjection { - fn default() -> Self { - Self { - format: FlowFormat::policy_debate(), - sheets: Vec::new(), - } - } -} - -impl FlowProjection { - pub fn validate(&self) -> anyhow::Result<()> { - let mut ids = HashSet::new(); - let mut column_levels = HashMap::new(); - if !ids.insert(self.format.id) { - bail!("duplicate format id"); - } - for sheet_type in &self.format.sheet_types { - if !ids.insert(sheet_type.id) || sheet_type.columns.is_empty() { - bail!("invalid sheet type {}", sheet_type.name); - } - for (level, column) in sheet_type.columns.iter().enumerate() { - if !ids.insert(column.id) { - bail!("duplicate column id"); - } - column_levels.insert(column.id, level); - } - } - for sheet in &self.sheets { - let definition = self - .format - .sheet_type(sheet.sheet_type_id) - .with_context(|| format!("sheet {} references unknown type", sheet.name))?; - if !ids.insert(sheet.id) { - bail!("duplicate sheet id"); - } - let valid_columns: HashSet<_> = definition.columns.iter().map(|column| column.id).collect(); - let cells: HashMap<_, _> = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); - if cells.len() != sheet.cells.len() { - bail!("sheet {} contains duplicate cell ids", sheet.name); - } - for cell in &sheet.cells { - if !valid_columns.contains(&cell.column_id) { - bail!("cell references a column outside its sheet type"); - } - let _ = cell - .document() - .context("cell contains invalid rich-text document")?; - if let Some(parent_id) = cell.parent_id { - let parent = cells - .get(&parent_id) - .context("cell references missing parent")?; - let child_level = column_levels[&cell.column_id]; - let parent_level = column_levels[&parent.column_id]; - if child_level != parent_level + 1 { - bail!("parent-child link must connect adjacent columns"); - } - } - } - for column in &definition.columns { - let column_cells: Vec<_> = sheet - .cells - .iter() - .filter(|cell| cell.column_id == column.id) - .collect(); - let mut completed_parents = HashSet::new(); - let mut current_parent = None; - for cell in column_cells { - if cell.parent_id != current_parent { - if let Some(parent) = current_parent { - completed_parents.insert(parent); - } - if cell - .parent_id - .is_some_and(|parent| completed_parents.contains(&parent)) - { - bail!("orphan or unrelated cell breaks a sibling run"); - } - current_parent = cell.parent_id; - } - } - } - if sheet - .annotations - .iter() - .any(|stroke| stroke.sheet_id != sheet.id) - { - bail!("annotation references the wrong sheet"); - } - } - Ok(()) - } -} - -pub struct FlowDocument { - loro: LoroDoc, - projection: FlowProjection, - undo_manager: UndoManager, -} - -impl Clone for FlowDocument { - fn clone(&self) -> Self { - Self::from_snapshot(&self.snapshot().expect("validated flow snapshot")).expect("validated flow clone") - } -} - -impl Default for FlowDocument { - fn default() -> Self { - Self::new() - } -} - -impl FlowDocument { - pub fn new() -> Self { - Self::from_projection(FlowProjection::default()).expect("default flow projection is valid") - } - - pub fn projection(&self) -> &FlowProjection { - &self.projection - } - - pub fn from_projection(projection: FlowProjection) -> anyhow::Result { - projection.validate()?; - let loro = LoroDoc::new(); - loro - .get_map("flow") - .insert(FORMAT_KEY, postcard::to_allocvec(&projection.format)?)?; - let annotations = loro.get_map(ANNOTATIONS_MAP); - for stroke in projection - .sheets - .iter() - .flat_map(|sheet| &sheet.annotations) - { - annotations.insert(&stroke.id.to_string(), postcard::to_allocvec(stroke)?)?; - } - write_all_entity_records(&loro, &projection)?; - loro.commit(); - let undo_manager = UndoManager::new(&loro); - Ok(Self { - loro, - projection, - undo_manager, - }) - } - - pub fn from_snapshot(snapshot: &[u8]) -> anyhow::Result { - let loro = LoroDoc::new(); - loro.import(snapshot)?; - let projection = load_projection(&loro)?; - let undo_manager = UndoManager::new(&loro); - Ok(Self { - loro, - projection, - undo_manager, - }) - } - - pub fn snapshot(&self) -> anyhow::Result> { - Ok(self.loro.export(ExportMode::Snapshot)?) - } - - pub fn projection_snapshot(&self) -> FlowProjectionSnapshot { - FlowProjectionSnapshot { - projection: self.projection.clone(), - frontier: self.frontier(), - version_vector: self.version_vector(), - } - } - - pub fn frontier(&self) -> FlowFrontier { - self.loro.state_frontiers().encode() - } - - pub fn export_updates_for(&self, version: &VersionVector) -> anyhow::Result { - Ok(self.loro.export(ExportMode::updates(version))?) - } - - pub fn updates_since(&self, version: &VersionVector) -> anyhow::Result { - self.export_updates_for(version) - } - - pub fn version_vector(&self) -> VersionVector { - self.loro.oplog_vv() - } - - pub fn import_remote_update(&mut self, bytes: &[u8]) -> anyhow::Result> { - self.loro.import(bytes)?; - self.reload_projection()?; - Ok(vec![ - FlowRuntimeEvent::RemoteUpdateApplied { - bytes_len: bytes.len(), - frontier: self.frontier(), - version_vector: self.version_vector(), - }, - FlowRuntimeEvent::ProjectionUpdated { - snapshot: Box::new(self.projection_snapshot()), - }, - ]) - } - - pub fn import_updates(&mut self, bytes: &[u8]) -> anyhow::Result<()> { - self.import_remote_update(bytes).map(|_| ()) - } - - pub fn apply_projection_transaction( - &mut self, - transaction_id: FlowTransactionId, - base_frontier: &[u8], - change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>, - ) -> anyhow::Result { - let actual_frontier = self.frontier(); - if !base_frontier.is_empty() && base_frontier != actual_frontier.as_slice() { - return Err( - StaleFlowProjectionError { - expected: base_frontier.to_vec(), - actual: actual_frontier, - } - .into(), - ); - } - - let base_frontier = actual_frontier; - let before_vv = self.version_vector(); - self.update(change)?; - let update = self.export_updates_for(&before_vv)?; - let new_frontier = self.frontier(); - let version_vector = self.version_vector(); - Ok(FlowCommitResult { - transaction_id, - base_frontier, - new_frontier: new_frontier.clone(), - events: vec![ - FlowRuntimeEvent::LocalUpdate { - bytes: update, - frontier: new_frontier, - version_vector: version_vector.clone(), - }, - FlowRuntimeEvent::ProjectionUpdated { - snapshot: Box::new(FlowProjectionSnapshot { - projection: self.projection.clone(), - frontier: self.frontier(), - version_vector, - }), - }, - ], - }) - } - - pub fn update(&mut self, change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>) -> anyhow::Result<()> { - // Mutate a draft clone so a failing `change` or a rejected validation leaves `self` untouched. - let mut draft = self.projection.clone(); - change(&mut draft)?; - draft.validate()?; - if draft.format != read_immutable_format(&self.loro)? { - bail!("flow format definitions are immutable"); - } - // Serialize the whole delta up front. Only once every fallible step has succeeded do we touch the - // Loro doc, so a rejected update can never leave half-written, uncommitted state behind. - let delta = compute_entity_delta(&self.projection, &draft)?; - apply_entity_delta(&self.loro, delta)?; - self.loro.commit(); - self.projection = draft; - Ok(()) - } - - pub fn can_undo(&self) -> bool { - self.undo_manager.can_undo() - } - - pub fn can_redo(&self) -> bool { - self.undo_manager.can_redo() - } - - pub fn undo(&mut self) -> anyhow::Result { - let changed = self.undo_manager.undo()?; - if changed { - self.reload_projection()?; - } - Ok(changed) - } - - pub fn redo(&mut self) -> anyhow::Result { - let changed = self.undo_manager.redo()?; - if changed { - self.reload_projection()?; - } - Ok(changed) - } - - fn reload_projection(&mut self) -> anyhow::Result<()> { - self.projection = load_projection(&self.loro)?; - Ok(()) - } -} - -/// Rebuild the projection from the immutable format plus the granular per-entity records. These -/// records are the source of truth for sheets, cells, and annotations — there is no whole-projection -/// blob to read back. -fn load_projection(loro: &LoroDoc) -> anyhow::Result { - let format = read_immutable_format(loro)?; - let mut projection = FlowProjection { format, sheets: Vec::new() }; - merge_entity_records_from_loro(&mut projection, loro)?; - projection.validate()?; - Ok(projection) -} - -fn read_immutable_format(loro: &LoroDoc) -> anyhow::Result { - let value = loro - .get_map("flow") - .get(FORMAT_KEY) - .context("Loro snapshot is missing immutable format definition")?; - let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { - bail!("Loro immutable format definition has invalid type"); - }; - Ok(postcard::from_bytes(&bytes)?) -} - -/// A single pending change to a Loro entity map: `Some(bytes)` inserts/updates a key, `None` deletes it. -type EntityOp = (&'static str, String, Option>); - -/// Serialize the full sheet/cell/annotation delta between two projections *without* touching the Loro -/// doc, so serialization failures can be surfaced before any state is mutated (see [`FlowDocument::update`]). -fn compute_entity_delta(before: &FlowProjection, after: &FlowProjection) -> anyhow::Result> { - let mut ops = Vec::new(); - push_record_ops(&mut ops, SHEETS_MAP, &sheet_records(before), &sheet_records(after))?; - push_record_ops(&mut ops, CELLS_MAP, &cell_records(before), &cell_records(after))?; - push_annotation_ops(&mut ops, before, after)?; - Ok(ops) -} - -fn push_record_ops( - ops: &mut Vec, - map_name: &'static str, - before: &HashMap, - after: &HashMap, -) -> anyhow::Result<()> { - for id in before.keys().filter(|id| !after.contains_key(id)) { - ops.push((map_name, id.to_string(), None)); - } - for (id, record) in after { - if before.get(id) != Some(record) { - ops.push((map_name, id.to_string(), Some(postcard::to_allocvec(record)?))); - } - } - Ok(()) -} - -fn push_annotation_ops(ops: &mut Vec, before: &FlowProjection, after: &FlowProjection) -> anyhow::Result<()> { - let before: HashMap<_, _> = before - .sheets - .iter() - .flat_map(|sheet| sheet.annotations.iter().map(|stroke| (stroke.id, stroke))) - .collect(); - let after: HashMap<_, _> = after - .sheets - .iter() - .flat_map(|sheet| sheet.annotations.iter().map(|stroke| (stroke.id, stroke))) - .collect(); - for id in before.keys().filter(|id| !after.contains_key(id)) { - ops.push((ANNOTATIONS_MAP, id.to_string(), None)); - } - for (id, stroke) in &after { - if before.get(id).is_none_or(|existing| *existing != *stroke) { - ops.push((ANNOTATIONS_MAP, id.to_string(), Some(postcard::to_allocvec(*stroke)?))); - } - } - Ok(()) -} - -/// Apply a pre-serialized delta to the Loro doc. This is the only step in [`FlowDocument::update`] that -/// mutates Loro state; it runs after every fallible step has already succeeded. -fn apply_entity_delta(loro: &LoroDoc, ops: Vec) -> anyhow::Result<()> { - for (map_name, key, value) in ops { - let map = loro.get_map(map_name); - match value { - Some(bytes) => map.insert(key.as_str(), bytes)?, - None => map.delete(key.as_str())?, - } - } - Ok(()) -} - -fn write_all_entity_records(loro: &LoroDoc, projection: &FlowProjection) -> anyhow::Result<()> { - for (id, record) in sheet_records(projection) { - loro - .get_map(SHEETS_MAP) - .insert(&id.to_string(), postcard::to_allocvec(&record)?)?; - } - for (id, record) in cell_records(projection) { - loro - .get_map(CELLS_MAP) - .insert(&id.to_string(), postcard::to_allocvec(&record)?)?; - } - Ok(()) -} - -fn sheet_records(projection: &FlowProjection) -> HashMap { - projection - .sheets - .iter() - .enumerate() - .map(|(order, sheet)| { - ( - sheet.id, - SheetRecord { - id: sheet.id, - name: sheet.name.clone(), - sheet_type_id: sheet.sheet_type_id, - order: order as u64, - }, - ) - }) - .collect() -} - -fn cell_records(projection: &FlowProjection) -> HashMap { - projection - .sheets - .iter() - .flat_map(|sheet| { - sheet.cells.iter().enumerate().map(|(order, cell)| { - ( - cell.id, - CellRecord { - sheet_id: sheet.id, - cell: cell.clone(), - order: order as u64, - }, - ) - }) - }) - .collect() -} - -fn merge_entity_records_from_loro(projection: &mut FlowProjection, loro: &LoroDoc) -> anyhow::Result<()> { - let mut sheets: Vec = read_record_map(loro, SHEETS_MAP)?; - sheets.sort_by_key(|record| (record.order, record.id)); - let existing_annotations: HashMap<_, _> = projection - .sheets - .iter_mut() - .map(|sheet| (sheet.id, std::mem::take(&mut sheet.annotations))) - .collect(); - projection.sheets = sheets - .into_iter() - .map(|record| Sheet { - id: record.id, - name: record.name, - sheet_type_id: record.sheet_type_id, - cells: Vec::new(), - annotations: existing_annotations - .get(&record.id) - .cloned() - .unwrap_or_default(), - }) - .collect(); - let mut cells: Vec = read_record_map(loro, CELLS_MAP)?; - cells.sort_by_key(|record| (record.sheet_id, record.order, record.cell.id)); - for record in cells { - if let Some(sheet) = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == record.sheet_id) - { - sheet.cells.push(record.cell); - } - } - merge_annotations_from_loro(projection, loro) -} - -fn read_record_map Deserialize<'de>>(loro: &LoroDoc, map_name: &str) -> anyhow::Result> { - let mut records = Vec::new(); - let mut error = None; - loro.get_map(map_name).for_each(|key, value| { - if error.is_some() { - return; - } - let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { - error = Some(anyhow::anyhow!("{map_name} record {key} has invalid Loro value type")); - return; - }; - match postcard::from_bytes(&bytes) { - Ok(record) => records.push(record), - Err(source) => error = Some(source.into()), - } - }); - error.map_or(Ok(records), Err) -} - -fn merge_annotations_from_loro(projection: &mut FlowProjection, loro: &LoroDoc) -> anyhow::Result<()> { - for sheet in &mut projection.sheets { - sheet.annotations.clear(); - } - let mut error = None; - loro.get_map(ANNOTATIONS_MAP).for_each(|key, value| { - if error.is_some() { - return; - } - let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value else { - error = Some(anyhow::anyhow!("annotation {key} has invalid Loro value type")); - return; - }; - let stroke: AnnotationStroke = match postcard::from_bytes(&bytes) { - Ok(stroke) => stroke, - Err(source) => { - error = Some(source.into()); - return; - }, - }; - if key != stroke.id.to_string() { - error = Some(anyhow::anyhow!("annotation key does not match stable stroke id")); - return; - } - if let Some(sheet) = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == stroke.sheet_id) - { - sheet.annotations.push(stroke); - } - }); - error.map_or(Ok(()), Err) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cell_with_paragraphs(paragraphs: Vec) -> Cell { - let document = flowstate_document::document_from_input(flowstate_document::flowstate_document_theme(), paragraphs); - Cell { - id: Uuid::new_v4(), - column_id: Uuid::new_v4(), - parent_id: None, - document_bytes: db8_bytes(&document).unwrap(), - } - } - - fn run(text: &str) -> flowstate_document::InputRun { - flowstate_document::InputRun { - text: text.into(), - styles: flowstate_document::RunStyles::default(), - } - } - - fn cite(text: &str) -> flowstate_document::InputRun { - let mut run = run(text); - run.styles.semantic = flowstate_document::SEMANTIC_CITE; - run - } - - #[test] - fn summary_projects_mixed_card_and_analytic_content_in_document_order() { - let cell = cell_with_paragraphs(vec![ - flowstate_document::InputParagraph { - style: flowstate_document::PARAGRAPH_TAG, - runs: vec![run("Tag")], - }, - flowstate_document::InputParagraph { - style: flowstate_document::ParagraphStyle::Normal, - runs: vec![run("hidden before "), cite("Cite"), run(" hidden after")], - }, - flowstate_document::InputParagraph { - style: flowstate_document::PARAGRAPH_UNDERTAG, - runs: vec![run("Undertag")], - }, - flowstate_document::InputParagraph { - style: flowstate_document::PARAGRAPH_ANALYTIC, - runs: vec![run("Analytic")], - }, - ]); - - assert_eq!(cell.summary_text().unwrap(), "Tag\nCite\nUndertag\nAnalytic"); - assert!(cell.uses_summary_projection().unwrap()); - } - - #[test] - fn round_trips_loro_snapshot() { - let mut document = FlowDocument::new(); - let sheet_type = document.projection().format.sheet_types[0].id; - document - .update(|projection| { - projection.sheets.push(Sheet { - id: Uuid::new_v4(), - name: "Case".into(), - sheet_type_id: sheet_type, - cells: Vec::new(), - annotations: Vec::new(), - }); - Ok(()) - }) - .unwrap(); - let restored = FlowDocument::from_snapshot(&document.snapshot().unwrap()).unwrap(); - assert_eq!(document.projection(), restored.projection()); - } - - #[test] - fn rejects_format_mutation() { - let mut document = FlowDocument::new(); - assert!( - document - .update(|projection| { - projection.format.name = "Changed".into(); - Ok(()) - }) - .is_err() - ); - } - - #[test] - fn collaboration_transaction_returns_update_and_projection_events() { - let mut document = FlowDocument::new(); - let base_frontier = document.frontier(); - let sheet_type = document.projection().format.sheet_types[0].id; - let sheet_id = Uuid::new_v4(); - - let commit = document - .apply_projection_transaction(7, &base_frontier, |projection| { - projection.sheets.push(Sheet { - id: sheet_id, - name: "Shared".into(), - sheet_type_id: sheet_type, - cells: Vec::new(), - annotations: Vec::new(), - }); - Ok(()) - }) - .unwrap(); - - assert_eq!(commit.transaction_id, 7); - assert_eq!(commit.base_frontier, base_frontier); - assert_eq!(commit.new_frontier, document.frontier()); - assert_eq!(commit.events.len(), 2); - assert!(matches!(commit.events[0], FlowRuntimeEvent::LocalUpdate { .. })); - assert!(matches!(commit.events[1], FlowRuntimeEvent::ProjectionUpdated { .. })); - } - - #[test] - fn collaboration_transaction_rejects_stale_frontier() { - let mut document = FlowDocument::new(); - let sheet_type = document.projection().format.sheet_types[0].id; - document.create_sheet("Case", sheet_type).unwrap(); - let stale_frontier = document.frontier(); - document - .update(|projection| { - projection.sheets[0].name = "Changed".into(); - Ok(()) - }) - .unwrap(); - - let error = document - .apply_projection_transaction(8, &stale_frontier, |projection| { - projection.sheets[0].name = "Rejected".into(); - Ok(()) - }) - .unwrap_err(); - - assert!(error.downcast_ref::().is_some()); - assert_eq!(document.projection().sheets[0].name, "Changed"); - } - - #[test] - fn collaboration_remote_update_reloads_projection() { - let mut source = FlowDocument::new(); - let sheet_type = source.projection().format.sheet_types[0].id; - source.create_sheet("Case", sheet_type).unwrap(); - let mut target = FlowDocument::from_snapshot(&source.snapshot().unwrap()).unwrap(); - let target_vv = target.version_vector(); - source - .update(|projection| { - projection.sheets[0].name = "Remote".into(); - Ok(()) - }) - .unwrap(); - let update = source.export_updates_for(&target_vv).unwrap(); - - let events = target.import_remote_update(&update).unwrap(); - - assert_eq!(target.projection().sheets[0].name, "Remote"); - assert_eq!(events.len(), 2); - assert!(matches!(events[0], FlowRuntimeEvent::RemoteUpdateApplied { .. })); - assert!(matches!(events[1], FlowRuntimeEvent::ProjectionUpdated { .. })); - } -} diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index dcbe0b6b..e49292cb 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -1,29 +1,25 @@ -mod collaboration; -mod document; -mod operations; -mod persistence; +//! The .fl0 flow FORMAT crate (CRDT-first, spec Part A) — what +//! `flowstate-document` is to `.db8`: the Loro container schema, the total +//! board materializer + normalization law, the plain-data intent vocabulary, +//! the pure board operations behind drag preview AND commit, and the FLOWFL0 +//! v2 container. The gated write path lives in `flowstate-collab::flow`. -// ---- .fl0 v2 (CRDT-first) — spec Parts A/B --------------------------------- pub mod board_ops; pub mod format; pub mod intents; pub mod loro_projection; pub mod loro_schema; +mod persistence; pub mod projection; -pub use collaboration::{ - FlowCommitResult, FlowFrontier, FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, StaleFlowProjectionError, -}; -pub use document::db8_bytes as cell_db8_bytes; -pub use document::{ - AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, Cell, CellId, ColumnDefinition, ColumnId, FlowDocument, - FlowFormat, FlowProjection, FormatId, Sheet, SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, +pub use format::{ + AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, CellId, ColumnDefinition, ColumnId, FlowFormat, FormatId, + SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, }; pub use intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; pub use loro::VersionVector; pub use loro_projection::FlowDefect; -pub use persistence::{ - FL0_VERSION, decode as decode_flow_document, decode_fl0_snapshot, encode as encode_flow_document, encode_fl0_snapshot, load_flow_document, - read_fl0, save_flow_document, write_fl0, -}; -pub use projection::{CellSummary, FlowBoardProjection}; +pub use persistence::{FL0_VERSION, decode_fl0_snapshot, encode_fl0_snapshot, read_fl0, write_fl0}; +pub use projection::{Cell, CellSummary, FlowBoardProjection, Sheet}; + +pub const FLOW_EXTENSION: &str = "fl0"; diff --git a/crates/flowstate-flow/src/operations.rs b/crates/flowstate-flow/src/operations.rs deleted file mode 100644 index f6acd10a..00000000 --- a/crates/flowstate-flow/src/operations.rs +++ /dev/null @@ -1,1167 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use anyhow::{Context as _, bail}; -use uuid::Uuid; - -use crate::{AnnotationOriginator, AnnotationStroke, Cell, CellId, FlowDocument, FlowProjection, Sheet, SheetId, SheetTypeId}; -pub use crate::intents::{FlowDropIntent, RelativePosition}; - -impl FlowDocument { - pub fn child_append_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let parent_index = sheet - .cells - .iter() - .position(|cell| cell.id == parent_id) - .context("unknown cell")?; - Ok( - sheet - .cells - .iter() - .enumerate() - .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent_id)) - .map(|(index, _)| index) - .max() - .unwrap_or(parent_index) - + 1, - ) - } - - pub fn child_prepend_index(&self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let parent_index = sheet - .cells - .iter() - .position(|cell| cell.id == parent_id) - .context("unknown cell")?; - Ok(parent_index + 1) - } - - pub fn deletion_fallback(&self, sheet_id: SheetId, cell_id: CellId) -> Option { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id)?; - let definition = self.projection().format.sheet_type(sheet.sheet_type_id)?; - let index = sheet.cells.iter().position(|cell| cell.id == cell_id)?; - let cell = &sheet.cells[index]; - - if let Some(previous) = sheet.cells[..index] - .iter() - .rev() - .find(|candidate| candidate.column_id == cell.column_id && candidate.parent_id == cell.parent_id) - { - return Some(previous.id); - } - if let Some(parent) = cell.parent_id { - return Some(parent); - } - - let column = definition - .columns - .iter() - .position(|column| column.id == cell.column_id)?; - let left_column = column - .checked_sub(1) - .and_then(|index| definition.columns.get(index))? - .id; - sheet - .cells - .iter() - .rev() - .find(|candidate| candidate.column_id == left_column && candidate.parent_id.is_none()) - .map(|candidate| candidate.id) - } - - pub fn create_sheet(&mut self, name: impl Into, sheet_type_id: SheetTypeId) -> anyhow::Result { - let id = Uuid::new_v4(); - let name = name.into(); - self.update(|projection| { - projection - .format - .sheet_type(sheet_type_id) - .context("unknown sheet type")?; - projection.sheets.push(Sheet { - id, - name, - sheet_type_id, - cells: Vec::new(), - annotations: Vec::new(), - }); - Ok(()) - })?; - Ok(id) - } - - pub fn rename_sheet(&mut self, sheet_id: SheetId, name: impl Into) -> anyhow::Result<()> { - let name = name.into(); - self.update(|projection| { - projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .name = name; - Ok(()) - }) - } - - pub fn delete_sheet(&mut self, sheet_id: SheetId) -> anyhow::Result<()> { - self.update(|projection| { - let before = projection.sheets.len(); - projection.sheets.retain(|sheet| sheet.id != sheet_id); - if projection.sheets.len() == before { - bail!("unknown sheet"); - } - Ok(()) - }) - } - - pub fn move_sheet(&mut self, sheet_id: SheetId, target_index: usize) -> anyhow::Result<()> { - self.update(|projection| { - let source = projection - .sheets - .iter() - .position(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let sheet = projection.sheets.remove(source); - projection - .sheets - .insert(target_index.min(projection.sheets.len()), sheet); - Ok(()) - }) - } - - pub fn add_plain_cell( - &mut self, - sheet_id: SheetId, - column_index: usize, - parent_id: Option, - insertion_index: Option, - ) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let definition = self - .projection() - .format - .sheet_type(sheet.sheet_type_id) - .context("unknown sheet type")?; - let column_id = definition - .columns - .get(column_index) - .context("column index out of range")? - .id; - let mut cell = Cell::plain(column_id)?; - cell.parent_id = parent_id; - let id = cell.id; - self.update(|projection| { - let sheet = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - sheet.cells.insert( - insertion_index - .unwrap_or(sheet.cells.len()) - .min(sheet.cells.len()), - cell, - ); - Ok(()) - })?; - Ok(id) - } - - pub fn add_orphan_at_column_top(&mut self, sheet_id: SheetId, column_index: usize) -> anyhow::Result { - self.add_plain_cell(sheet_id, column_index, None, Some(0)) - } - - pub fn add_sibling(&mut self, sheet_id: SheetId, cell_id: CellId, position: RelativePosition) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let index = sheet - .cells - .iter() - .position(|cell| cell.id == cell_id) - .context("unknown cell")?; - let source = &sheet.cells[index]; - let definition = self - .projection() - .format - .sheet_type(sheet.sheet_type_id) - .context("unknown sheet type")?; - let column = definition - .columns - .iter() - .position(|column| column.id == source.column_id) - .context("unknown column")?; - let insertion = match position { - RelativePosition::Before => index, - RelativePosition::After => index + 1, - }; - self.add_plain_cell(sheet_id, column, source.parent_id, Some(insertion)) - } - - pub fn add_response(&mut self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let parent = sheet - .cells - .iter() - .find(|cell| cell.id == parent_id) - .context("unknown cell")?; - let definition = self - .projection() - .format - .sheet_type(sheet.sheet_type_id) - .context("unknown sheet type")?; - let parent_column = definition - .columns - .iter() - .position(|column| column.id == parent.column_id) - .context("unknown column")?; - let child_column = parent_column + 1; - if child_column >= definition.columns.len() { - bail!("rightmost cells cannot receive responses"); - } - let insertion = self.child_append_index(sheet_id, parent_id)?; - self.add_plain_cell(sheet_id, child_column, Some(parent_id), Some(insertion)) - } - - pub fn add_first_response(&mut self, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let parent = sheet - .cells - .iter() - .find(|cell| cell.id == parent_id) - .context("unknown cell")?; - let definition = self - .projection() - .format - .sheet_type(sheet.sheet_type_id) - .context("unknown sheet type")?; - let parent_column = definition - .columns - .iter() - .position(|column| column.id == parent.column_id) - .context("unknown column")?; - let child_column = parent_column + 1; - if child_column >= definition.columns.len() { - bail!("rightmost cells cannot receive responses"); - } - let insertion = self.child_prepend_index(sheet_id, parent_id)?; - self.add_plain_cell(sheet_id, child_column, Some(parent_id), Some(insertion)) - } - - pub fn delete_cell(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { - self.update(|projection| { - let sheet = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let before = sheet.cells.len(); - sheet.cells.retain(|cell| cell.id != cell_id); - if sheet.cells.len() == before { - bail!("unknown cell"); - } - for cell in &mut sheet.cells { - if cell.parent_id == Some(cell_id) { - cell.parent_id = None; - } - } - Ok(()) - }) - } - - pub fn strike_cell(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { - self.update(|projection| { - let cell = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .cells - .iter_mut() - .find(|cell| cell.id == cell_id) - .context("unknown cell")?; - let mut document = cell.document()?; - { - let mut paragraphs = document.paragraphs.make_mut(); - let struck = paragraphs - .iter() - .flat_map(|paragraph| ¶graph.runs) - .all(|run| run.styles.strikethrough); - for paragraph in &mut *paragraphs { - for run in &mut paragraph.runs { - run.styles.strikethrough = !struck; - } - paragraph.version = paragraph.version.wrapping_add(1); - } - } - document.blocks = - flowstate_document::BlockSeq::from_vec(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs.to_vec())); - cell.document_bytes = crate::document::db8_bytes(&document)?; - Ok(()) - }) - } - - pub fn replace_cell_document( - &mut self, - sheet_id: SheetId, - cell_id: CellId, - document: &flowstate_document::DocumentProjection, - ) -> anyhow::Result<()> { - let bytes = crate::document::db8_bytes(document)?; - self.update(|projection| { - projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .cells - .iter_mut() - .find(|cell| cell.id == cell_id) - .context("unknown cell")? - .document_bytes = bytes; - Ok(()) - }) - } - - pub fn ensure_cell_editable_projection(&mut self, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result { - let cell = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .cells - .iter() - .find(|cell| cell.id == cell_id) - .context("unknown cell")?; - if cell.uses_summary_projection()? { - return Ok(false); - } - self.update(|projection| { - let cell = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .cells - .iter_mut() - .find(|cell| cell.id == cell_id) - .context("unknown cell")?; - let mut document = cell.document()?; - let mut paragraphs = document.paragraphs.make_mut(); - let paragraph = paragraphs - .first_mut() - .context("cell document has no paragraph")?; - paragraph.style = flowstate_document::PARAGRAPH_TAG; - paragraph.version = paragraph.version.wrapping_add(1); - drop(paragraphs); - document.blocks = - flowstate_document::BlockSeq::from_vec(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs.to_vec())); - cell.document_bytes = crate::document::db8_bytes(&document)?; - Ok(()) - })?; - Ok(true) - } - - pub fn move_cell( - &mut self, - sheet_id: SheetId, - cell_id: CellId, - target_column: usize, - target_index: usize, - new_parent: Option, - ) -> anyhow::Result<()> { - let intent = new_parent.map_or( - FlowDropIntent::RootInColumn { - column_index: target_column, - insertion_index: target_index, - }, - FlowDropIntent::LastChildOf, - ); - self.move_cell_subtree(sheet_id, cell_id, intent) - } - - pub fn move_cell_subtree(&mut self, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { - self.update(|projection| { - let column_ids = sheet_column_ids(projection, sheet_id)?; - let sheet = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - apply_move_subtree(sheet, &column_ids, cell_id, intent) - }) - } - - /// Read-only preview of [`Self::move_cell_subtree`]: returns a clone of the sheet with the drag - /// already applied, or `None` if the move is invalid (self/descendant drop, or a descendant would - /// fall outside the sheet columns). Nothing is committed, so this is safe to call every drag frame. - pub fn preview_move_cell_subtree(&self, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> Option { - let projection = self.projection(); - let column_ids = sheet_column_ids(projection, sheet_id).ok()?; - let mut sheet = projection - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id)? - .clone(); - apply_move_subtree(&mut sheet, &column_ids, cell_id, intent).ok()?; - // Only report a landing the real (fully validated) move would accept, so the drag preview never - // shows the cell dropping somewhere the commit will silently reject. - sheet_topology_ok(&sheet, &column_ids).then_some(sheet) - } - - /// Read-only preview of the sheet with the dragged cell and its whole subtree lifted out, so the - /// board can reflow as if it were gone while a drag is in flight and no valid target is hovered yet. - pub fn preview_without_subtree(&self, sheet_id: SheetId, cell_id: CellId) -> Option { - let sheet = self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id)?; - let subtree: HashSet = subtree_cell_ids(sheet, cell_id).into_iter().collect(); - if subtree.is_empty() { - return None; - } - let mut sheet = sheet.clone(); - sheet.cells.retain(|cell| !subtree.contains(&cell.id)); - Some(sheet) - } - - /// The dragged cell plus every descendant, in sheet order. Empty if the cell is unknown. - pub fn subtree_cell_ids_for(&self, sheet_id: SheetId, cell_id: CellId) -> Vec { - self - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .map(|sheet| subtree_cell_ids(sheet, cell_id)) - .unwrap_or_default() - } - - pub fn add_annotation(&mut self, sheet_id: SheetId, stroke: AnnotationStroke) -> anyhow::Result<()> { - self.update(|projection| { - if stroke.sheet_id != sheet_id { - bail!("annotation sheet id mismatch"); - } - projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")? - .annotations - .push(stroke); - Ok(()) - }) - } - - pub fn clear_annotations(&mut self, sheet_id: SheetId, originator: &AnnotationOriginator) -> anyhow::Result<()> { - self.update(|projection| { - let sheet = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - sheet - .annotations - .retain(|stroke| &stroke.originator != originator); - Ok(()) - }) - } - - pub fn clear_all_annotations(&mut self, originator: &AnnotationOriginator) -> anyhow::Result<()> { - self.update(|projection| { - for sheet in &mut projection.sheets { - sheet - .annotations - .retain(|stroke| &stroke.originator != originator); - } - Ok(()) - }) - } - - pub fn delete_annotation(&mut self, sheet_id: SheetId, stroke_id: uuid::Uuid, originator: &AnnotationOriginator) -> anyhow::Result { - let mut removed = false; - self.update(|projection| { - let sheet = projection - .sheets - .iter_mut() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let before = sheet.annotations.len(); - sheet - .annotations - .retain(|stroke| stroke.id != stroke_id || &stroke.originator != originator); - removed = sheet.annotations.len() != before; - Ok(()) - })?; - Ok(removed) - } -} - -fn is_descendant_of(sheet: &Sheet, cell_id: CellId, ancestor_id: CellId) -> bool { - let mut parent = sheet - .cells - .iter() - .find(|cell| cell.id == cell_id) - .and_then(|cell| cell.parent_id); - while let Some(parent_id) = parent { - if parent_id == ancestor_id { - return true; - } - parent = sheet - .cells - .iter() - .find(|cell| cell.id == parent_id) - .and_then(|cell| cell.parent_id); - } - false -} - -fn subtree_cell_ids(sheet: &Sheet, root_id: CellId) -> Vec { - sheet - .cells - .iter() - .filter(|cell| cell.id == root_id || is_descendant_of(sheet, cell.id, root_id)) - .map(|cell| cell.id) - .collect() -} - -/// Cheap structural check of the two move-sensitive invariants — parent/child column adjacency and -/// contiguous sibling runs per column — without deserializing any cell documents. A move never -/// touches cell content, so if the sheet was valid before, this is exactly the set of failures a move -/// can introduce, matching what [`FlowProjection::validate`] would reject. -fn sheet_topology_ok(sheet: &Sheet, column_ids: &[Uuid]) -> bool { - let level = |column: Uuid| column_ids.iter().position(|candidate| *candidate == column); - let cells: HashMap = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); - for cell in &sheet.cells { - let Some(cell_level) = level(cell.column_id) else { - return false; - }; - if let Some(parent_id) = cell.parent_id { - let Some(parent) = cells.get(&parent_id) else { - return false; - }; - let Some(parent_level) = level(parent.column_id) else { - return false; - }; - if cell_level != parent_level + 1 { - return false; - } - } - } - for &column in column_ids { - let mut completed_parents = HashSet::new(); - let mut current_parent = None; - for cell in sheet.cells.iter().filter(|cell| cell.column_id == column) { - if cell.parent_id != current_parent { - if let Some(parent) = current_parent { - completed_parents.insert(parent); - } - if cell - .parent_id - .is_some_and(|parent| completed_parents.contains(&parent)) - { - return false; - } - current_parent = cell.parent_id; - } - } - } - true -} - -fn sheet_column_ids(projection: &FlowProjection, sheet_id: SheetId) -> anyhow::Result> { - let sheet = projection - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .context("unknown sheet")?; - let definition = projection - .format - .sheet_type(sheet.sheet_type_id) - .context("unknown sheet type")?; - Ok(definition.columns.iter().map(|column| column.id).collect()) -} - -/// Move `cell_id` (and its subtree) within `sheet` according to `intent`. Pure over the sheet so the -/// same algorithm backs both the committing [`FlowDocument::move_cell_subtree`] and the read-only -/// drag previews. -fn apply_move_subtree(sheet: &mut Sheet, column_ids: &[Uuid], cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { - let source = sheet - .cells - .iter() - .position(|cell| cell.id == cell_id) - .context("unknown cell")?; - let source_level = column_ids - .iter() - .position(|column| *column == sheet.cells[source].column_id) - .context("source cell references unknown column")?; - - let (target_column, target_index, new_parent) = resolve_drop_intent(sheet, column_ids, cell_id, intent)?; - let level_delta = target_column as isize - source_level as isize; - let subtree_ids = subtree_cell_ids(sheet, cell_id); - if let Some(parent) = new_parent - && subtree_ids.contains(&parent) - { - bail!("cannot move a cell into itself or one of its descendants"); - } - let removed_before_target = sheet - .cells - .iter() - .take(target_index.min(sheet.cells.len())) - .filter(|cell| subtree_ids.contains(&cell.id)) - .count(); - - let mut subtree = Vec::new(); - let mut remaining = Vec::with_capacity(sheet.cells.len()); - for cell in sheet.cells.drain(..) { - if subtree_ids.contains(&cell.id) { - subtree.push(cell); - } else { - remaining.push(cell); - } - } - let insertion_index = target_index - .saturating_sub(removed_before_target) - .min(remaining.len()); - - for cell in &mut subtree { - let old_level = column_ids - .iter() - .position(|column| *column == cell.column_id) - .context("subtree cell references unknown column")?; - let new_level = old_level - .checked_add_signed(level_delta) - .filter(|level| *level < column_ids.len()) - .context("moving this subtree would place a descendant outside the sheet columns")?; - cell.column_id = column_ids[new_level]; - if cell.id == cell_id { - cell.parent_id = new_parent; - } - } - remaining.splice(insertion_index..insertion_index, subtree); - sheet.cells = remaining; - Ok(()) -} - -fn resolve_drop_intent( - sheet: &Sheet, - column_ids: &[uuid::Uuid], - dragged: CellId, - intent: FlowDropIntent, -) -> anyhow::Result<(usize, usize, Option)> { - match intent { - FlowDropIntent::BeforeSibling(target) | FlowDropIntent::AfterSibling(target) => { - if dragged == target { - bail!("cannot drop a cell relative to itself"); - } - if is_descendant_of(sheet, target, dragged) { - bail!("cannot drop a cell relative to one of its descendants"); - } - let target_index = sheet - .cells - .iter() - .position(|cell| cell.id == target) - .context("unknown target cell")?; - let target_cell = &sheet.cells[target_index]; - let column_index = column_ids - .iter() - .position(|column| *column == target_cell.column_id) - .context("target cell references unknown column")?; - let insertion_index = match intent { - FlowDropIntent::BeforeSibling(_) => target_index, - FlowDropIntent::AfterSibling(_) => target_index + 1, - FlowDropIntent::FirstChildOf(_) | FlowDropIntent::LastChildOf(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), - }; - Ok((column_index, insertion_index, target_cell.parent_id)) - }, - FlowDropIntent::FirstChildOf(parent) | FlowDropIntent::LastChildOf(parent) => { - if dragged == parent { - bail!("cannot drop a cell onto itself"); - } - if is_descendant_of(sheet, parent, dragged) { - bail!("cannot drop a cell onto one of its descendants"); - } - let parent_index = sheet - .cells - .iter() - .position(|cell| cell.id == parent) - .context("unknown parent cell")?; - let parent_cell = &sheet.cells[parent_index]; - let parent_column = column_ids - .iter() - .position(|column| *column == parent_cell.column_id) - .context("parent cell references unknown column")?; - let child_column = parent_column - .checked_add(1) - .filter(|index| *index < column_ids.len()) - .context("parent has no child column")?; - let insertion_index = match intent { - FlowDropIntent::FirstChildOf(_) => parent_index + 1, - FlowDropIntent::LastChildOf(_) => { - sheet - .cells - .iter() - .enumerate() - .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent)) - .map(|(index, _)| index) - .max() - .unwrap_or(parent_index) - + 1 - }, - FlowDropIntent::BeforeSibling(_) | FlowDropIntent::AfterSibling(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), - }; - Ok((child_column, insertion_index, Some(parent))) - }, - FlowDropIntent::RootInColumn { - column_index, - insertion_index, - } => { - if column_index >= column_ids.len() { - bail!("column index out of range"); - } - Ok((column_index, insertion_index, None)) - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{BoardPoint, BoardRect, StrokeStyle}; - - fn document_with_sheet() -> (FlowDocument, SheetId) { - let mut document = FlowDocument::new(); - let sheet_type = document.projection().format.sheet_types[0].id; - let sheet = document.create_sheet("Case", sheet_type).unwrap(); - (document, sheet) - } - - #[test] - fn deleting_parent_orphans_children() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let child = document.add_response(sheet, parent).unwrap(); - document.delete_cell(sheet, parent).unwrap(); - assert_eq!( - document.projection().sheets[0] - .cells - .iter() - .find(|cell| cell.id == child) - .unwrap() - .parent_id, - None - ); - } - - #[test] - fn moving_cell_down_uses_pre_removal_insertion_index() { - let (mut document, sheet_id) = document_with_sheet(); - let first = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); - let second = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); - let third = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); - - document.move_cell(sheet_id, first, 0, 2, None).unwrap(); - - let ids: Vec<_> = document.projection().sheets[0] - .cells - .iter() - .map(|cell| cell.id) - .collect(); - assert_eq!(ids, vec![second, first, third]); - } - - #[test] - fn unsupported_cell_content_becomes_an_editable_tag_without_losing_text() { - let (mut document, sheet_id) = document_with_sheet(); - let cell_id = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); - let source = flowstate_document::document_from_input( - flowstate_document::flowstate_document_theme(), - vec![flowstate_document::InputParagraph { - style: flowstate_document::ParagraphStyle::Normal, - runs: vec![flowstate_document::InputRun { - text: "Preserve me".into(), - styles: flowstate_document::RunStyles::default(), - }], - }], - ); - document - .replace_cell_document(sheet_id, cell_id, &source) - .unwrap(); - - assert!( - document - .ensure_cell_editable_projection(sheet_id, cell_id) - .unwrap() - ); - let cell = &document.projection().sheets[0].cells[0]; - assert_eq!(cell.summary_text().unwrap(), "Preserve me"); - assert_eq!(cell.document().unwrap().paragraphs[0].style, flowstate_document::PARAGRAPH_TAG); - } - - #[test] - fn quick_responses_append_after_existing_children() { - let (mut document, sheet_id) = document_with_sheet(); - let parent = document.add_plain_cell(sheet_id, 0, None, None).unwrap(); - let first = document.add_response(sheet_id, parent).unwrap(); - let grandchild = document.add_response(sheet_id, first).unwrap(); - let second = document.add_response(sheet_id, parent).unwrap(); - - let ids: Vec<_> = document.projection().sheets[0] - .cells - .iter() - .map(|cell| cell.id) - .collect(); - assert_eq!(ids, vec![parent, first, grandchild, second]); - } - - #[test] - fn deletion_fallback_prefers_previous_sibling_then_parent() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let first = document - .add_plain_cell(sheet, 1, Some(parent), None) - .unwrap(); - let second = document - .add_plain_cell(sheet, 1, Some(parent), None) - .unwrap(); - assert_eq!(document.deletion_fallback(sheet, second), Some(first)); - assert_eq!(document.deletion_fallback(sheet, first), Some(parent)); - } - - #[test] - fn deletion_fallback_for_orphan_prefers_same_column_then_left_column() { - let (mut document, sheet) = document_with_sheet(); - let left_first = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let left_last = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let orphan_first = document.add_plain_cell(sheet, 1, None, None).unwrap(); - let orphan_second = document.add_plain_cell(sheet, 1, None, None).unwrap(); - assert_eq!(document.deletion_fallback(sheet, orphan_second), Some(orphan_first)); - assert_eq!(document.deletion_fallback(sheet, orphan_first), Some(left_last)); - assert_eq!(document.deletion_fallback(sheet, left_first), None); - } - - #[test] - fn moving_parent_as_subtree_preserves_child_links() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let child = document.add_response(sheet, parent).unwrap(); - let grandchild = document.add_response(sheet, child).unwrap(); - document - .move_cell_subtree( - sheet, - parent, - FlowDropIntent::RootInColumn { - column_index: 1, - insertion_index: 0, - }, - ) - .unwrap(); - let sheet = &document.projection().sheets[0]; - let parent_cell = sheet.cells.iter().find(|cell| cell.id == parent).unwrap(); - let child_cell = sheet.cells.iter().find(|cell| cell.id == child).unwrap(); - let grandchild_cell = sheet - .cells - .iter() - .find(|cell| cell.id == grandchild) - .unwrap(); - let columns = &document.projection().format.sheet_types[0].columns; - assert_eq!(parent_cell.column_id, columns[1].id); - assert_eq!(parent_cell.parent_id, None); - assert_eq!(child_cell.column_id, columns[2].id); - assert_eq!(child_cell.parent_id, Some(parent)); - assert_eq!(grandchild_cell.column_id, columns[3].id); - assert_eq!(grandchild_cell.parent_id, Some(child)); - } - - #[test] - fn moving_parent_subtree_keeps_descendants_contiguous() { - let (mut document, sheet) = document_with_sheet(); - let other = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let child = document.add_response(sheet, parent).unwrap(); - let grandchild = document.add_response(sheet, child).unwrap(); - document - .move_cell_subtree(sheet, parent, FlowDropIntent::BeforeSibling(other)) - .unwrap(); - let sheet = &document.projection().sheets[0]; - assert_eq!( - sheet.cells.iter().map(|cell| cell.id).collect::>(), - vec![parent, child, grandchild, other] - ); - } - - #[test] - fn preview_move_matches_the_committed_move() { - let (mut document, sheet) = document_with_sheet(); - let first = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let second = document.add_plain_cell(sheet, 0, None, None).unwrap(); - - let preview = document - .preview_move_cell_subtree(sheet, first, FlowDropIntent::AfterSibling(second)) - .unwrap(); - document - .move_cell_subtree(sheet, first, FlowDropIntent::AfterSibling(second)) - .unwrap(); - - assert_eq!( - preview.cells.iter().map(|cell| cell.id).collect::>(), - document.projection().sheets[0] - .cells - .iter() - .map(|cell| cell.id) - .collect::>() - ); - } - - #[test] - fn preview_without_subtree_lifts_the_whole_family() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let _child = document.add_response(sheet, parent).unwrap(); - let other = document.add_plain_cell(sheet, 0, None, None).unwrap(); - - let lifted = document.preview_without_subtree(sheet, parent).unwrap(); - - assert_eq!(lifted.cells.iter().map(|cell| cell.id).collect::>(), vec![other]); - } - - #[test] - fn preview_move_rejects_targets_that_would_split_a_sibling_run() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let _first = document.add_response(sheet, parent).unwrap(); - let second = document.add_response(sheet, parent).unwrap(); - let orphan = document.add_plain_cell(sheet, 1, None, None).unwrap(); - - // Dropping the parentless `orphan` between `parent`'s two responses would split their sibling run, - // which the committed move rejects — so the preview must decline to show a landing there. - let insertion_index = document.projection().sheets[0] - .cells - .iter() - .position(|cell| cell.id == second) - .unwrap(); - let intent = FlowDropIntent::RootInColumn { - column_index: 1, - insertion_index, - }; - - assert!( - document - .preview_move_cell_subtree(sheet, orphan, intent) - .is_none() - ); - assert!(document.move_cell_subtree(sheet, orphan, intent).is_err()); - } - - #[test] - fn preview_move_onto_descendant_returns_none() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let child = document.add_response(sheet, parent).unwrap(); - - assert!( - document - .preview_move_cell_subtree(sheet, parent, FlowDropIntent::LastChildOf(child)) - .is_none() - ); - } - - #[test] - fn moving_cell_onto_descendant_is_rejected() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let child = document.add_response(sheet, parent).unwrap(); - - let error = document - .move_cell_subtree(sheet, parent, FlowDropIntent::LastChildOf(child)) - .unwrap_err(); - - assert!(error.to_string().contains("descendants")); - let sheet = &document.projection().sheets[0]; - assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![parent, child]); - assert_eq!(sheet.cells[1].parent_id, Some(parent)); - } - - #[test] - fn undo_and_redo_restore_projection() { - let (mut document, sheet) = document_with_sheet(); - document.add_plain_cell(sheet, 0, None, None).unwrap(); - assert_eq!(document.projection().sheets[0].cells.len(), 1); - assert!(document.undo().unwrap()); - assert!(document.projection().sheets[0].cells.is_empty()); - assert!(document.redo().unwrap()); - assert_eq!(document.projection().sheets[0].cells.len(), 1); - } - - #[test] - fn newly_added_top_orphan_precedes_every_existing_run() { - let (mut document, sheet) = document_with_sheet(); - let first = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let second = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let newest = document.add_orphan_at_column_top(sheet, 1).unwrap(); - let cells = &document.projection().sheets[0].cells; - assert_eq!(cells.iter().map(|cell| cell.id).collect::>(), vec![newest, first, second]); - } - - #[test] - fn striking_cell_updates_all_serialized_paragraph_runs() { - let (mut document, sheet) = document_with_sheet(); - let cell = document.add_plain_cell(sheet, 0, None, None).unwrap(); - document.strike_cell(sheet, cell).unwrap(); - let cell = &document.projection().sheets[0].cells[0]; - let rich_text = cell.document().unwrap(); - assert!( - rich_text - .paragraphs - .iter() - .flat_map(|paragraph| ¶graph.runs) - .all(|run| run.styles.strikethrough) - ); - assert!(rich_text.blocks.iter().all(|block| match block { - flowstate_document::Block::Paragraph(paragraph) => paragraph.runs.iter().all(|run| run.styles.strikethrough), - _ => true, - })); - document.strike_cell(sheet, cell.id).unwrap(); - let rich_text = document.projection().sheets[0].cells[0].document().unwrap(); - assert!( - rich_text - .paragraphs - .iter() - .flat_map(|paragraph| ¶graph.runs) - .all(|run| !run.styles.strikethrough) - ); - } - - #[test] - fn first_response_precedes_existing_child_subtrees() { - let (mut document, sheet) = document_with_sheet(); - let parent = document.add_plain_cell(sheet, 0, None, None).unwrap(); - let existing = document.add_response(sheet, parent).unwrap(); - let grandchild = document.add_response(sheet, existing).unwrap(); - let first = document.add_first_response(sheet, parent).unwrap(); - let ids = document.projection().sheets[0] - .cells - .iter() - .map(|cell| cell.id) - .collect::>(); - assert_eq!(ids, vec![parent, first, existing, grandchild]); - } - - #[test] - fn concurrent_annotation_adds_merge_additively() { - let (base, sheet) = document_with_sheet(); - let snapshot = base.snapshot().unwrap(); - let mut one = FlowDocument::from_snapshot(&snapshot).unwrap(); - let mut two = FlowDocument::from_snapshot(&snapshot).unwrap(); - one - .add_annotation(sheet, test_stroke(sheet, "one")) - .unwrap(); - two - .add_annotation(sheet, test_stroke(sheet, "two")) - .unwrap(); - let one_updates = one.updates_since(&two.version_vector()).unwrap(); - let two_updates = two.updates_since(&one.version_vector()).unwrap(); - one.import_updates(&two_updates).unwrap(); - two.import_updates(&one_updates).unwrap(); - assert_eq!(one.projection().sheets[0].annotations.len(), 2); - assert_eq!(two.projection().sheets[0].annotations.len(), 2); - } - - #[test] - fn clearing_annotations_removes_only_originators_own_strokes_across_sheets() { - let (mut document, first_sheet) = document_with_sheet(); - let sheet_type = document.projection().format.sheet_types[0].id; - let second_sheet = document.create_sheet("Other", sheet_type).unwrap(); - for sheet in [first_sheet, second_sheet] { - document - .add_annotation(sheet, test_stroke(sheet, "local")) - .unwrap(); - document - .add_annotation(sheet, test_stroke(sheet, "collaborator")) - .unwrap(); - } - - document - .clear_all_annotations(&AnnotationOriginator("local".into())) - .unwrap(); - - assert!( - document - .projection() - .sheets - .iter() - .all(|sheet| { sheet.annotations.len() == 1 && sheet.annotations[0].originator == AnnotationOriginator("collaborator".into()) }) - ); - } - - #[test] - fn concurrent_cell_adds_merge_by_stable_id() { - let (base, sheet) = document_with_sheet(); - let snapshot = base.snapshot().unwrap(); - let mut one = FlowDocument::from_snapshot(&snapshot).unwrap(); - let mut two = FlowDocument::from_snapshot(&snapshot).unwrap(); - one.add_plain_cell(sheet, 0, None, None).unwrap(); - two.add_plain_cell(sheet, 0, None, None).unwrap(); - exchange_updates(&mut one, &mut two); - assert_eq!(one.projection().sheets[0].cells.len(), 2); - assert_eq!(two.projection().sheets[0].cells.len(), 2); - } - - fn exchange_updates(one: &mut FlowDocument, two: &mut FlowDocument) { - let one_updates = one.updates_since(&two.version_vector()).unwrap(); - let two_updates = two.updates_since(&one.version_vector()).unwrap(); - one.import_updates(&two_updates).unwrap(); - two.import_updates(&one_updates).unwrap(); - } - - fn test_stroke(sheet_id: SheetId, originator: &str) -> AnnotationStroke { - AnnotationStroke { - id: Uuid::new_v4(), - sheet_id, - originator: AnnotationOriginator(originator.into()), - points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 2.0, y: 2.0 }], - style: StrokeStyle { - color_rgba: 0xff00_00ff, - width: 2.0, - opacity: 0.5, - }, - bbox: BoardRect { - min: BoardPoint { x: 0.0, y: 0.0 }, - max: BoardPoint { x: 2.0, y: 2.0 }, - }, - } - } -} diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index 4db6b2e9..a124649e 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -5,10 +5,7 @@ use std::path::Path; use anyhow::{Context as _, bail}; use uuid::Uuid; -use crate::document::FlowDocument; - const MAGIC: &[u8; 8] = b"FLOWFL0\0"; -const VERSION: u32 = 1; /// The v2 container (spec Part A): same magic/framing, payload is the /// zstd-compressed Loro snapshot of a v2 schema doc. v1 (the pre-release /// record-blob format) is explicitly rejected — it never shipped. @@ -97,75 +94,6 @@ pub fn write_fl0(path: impl AsRef, snapshot: &[u8]) -> anyhow::Result<()> Ok(()) } -pub fn load_flow_document(path: impl AsRef) -> anyhow::Result { - let path = path.as_ref(); - let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; - if metadata.len() > MAX_FL0_BYTES { - bail!( - "refusing to read {}: file too large ({} bytes > {} bytes)", - path.display(), - metadata.len(), - MAX_FL0_BYTES - ); - } - decode(&fs::read(path)?) -} - -pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> anyhow::Result<()> { - let path = path.as_ref(); - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent)?; - } - // Write-then-rename so a crash mid-save can't leave a truncated .fl0 in - // place of the previous good document. - let temp_path = path.with_file_name(format!( - ".{}.{}.tmp", - path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("flowstate"), - Uuid::new_v4() - )); - fs::write(&temp_path, encode(document)?).with_context(|| format!("failed to write temporary {}", temp_path.display()))?; - fs::rename(&temp_path, path).with_context(|| format!("failed to atomically replace {} with {}", path.display(), temp_path.display()))?; - Ok(()) -} - -pub fn encode(document: &FlowDocument) -> anyhow::Result> { - let snapshot = document.snapshot()?; - let compressed = zstd::stream::encode_all(snapshot.as_slice(), 3)?; - let mut bytes = Vec::with_capacity(MAGIC.len() + 8 + compressed.len()); - bytes.extend_from_slice(MAGIC); - bytes.extend_from_slice(&VERSION.to_le_bytes()); - bytes.extend_from_slice(&(compressed.len() as u64).to_le_bytes()); - bytes.extend_from_slice(&compressed); - Ok(bytes) -} - -pub fn decode(bytes: &[u8]) -> anyhow::Result { - let mut cursor = Cursor::new(bytes); - let mut magic = [0; 8]; - cursor.read_exact(&mut magic)?; - if &magic != MAGIC { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 signature").into()); - } - let version = read_u32(&mut cursor)?; - if version != VERSION { - return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported .fl0 version {version}")).into()); - } - let payload_len = read_u64(&mut cursor)? as usize; - if payload_len != bytes.len().saturating_sub(cursor.position() as usize) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 payload length").into()); - } - let mut compressed = Vec::with_capacity(payload_len); - cursor.read_to_end(&mut compressed)?; - let snapshot = zstd::stream::decode_all(compressed.as_slice())?; - FlowDocument::from_snapshot(&snapshot) -} - fn read_u32(cursor: &mut Cursor<&[u8]>) -> io::Result { let mut bytes = [0; 4]; cursor.read_exact(&mut bytes)?; @@ -202,8 +130,7 @@ mod tests { let dir = test_dir("flowstate-fl0-size"); let path = dir.join("oversized.fl0"); fs::write(&path, vec![b'{'; (MAX_FL0_BYTES as usize) + 1]).unwrap(); - - let error = load_flow_document(&path).map(|_| ()).unwrap_err(); + let error = read_fl0(&path).map(|_| ()).unwrap_err(); assert!(error.to_string().contains("file too large")); } @@ -212,8 +139,7 @@ mod tests { let dir = test_dir("flowstate-fl0-corrupt"); let path = dir.join("broken.fl0"); fs::write(&path, b"{not a flow document").unwrap(); - - assert!(load_flow_document(&path).is_err()); + assert!(read_fl0(&path).is_err()); assert_eq!(fs::read(&path).unwrap(), b"{not a flow document"); } @@ -221,27 +147,24 @@ mod tests { fn save_replaces_atomically_without_leaving_temp_files() { let dir = test_dir("flowstate-fl0-atomic"); let path = dir.join("doc.fl0"); - let document = FlowDocument::new(); - save_flow_document(&path, &document).unwrap(); - save_flow_document(&path, &document).unwrap(); - + write_fl0(&path, b"snapshot bytes").unwrap(); + write_fl0(&path, b"snapshot bytes").unwrap(); let entries: Vec<_> = fs::read_dir(&dir) .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .collect(); assert_eq!(entries, vec!["doc.fl0".to_owned()]); - assert!(decode(&fs::read(&path).unwrap()).is_ok()); + assert_eq!(read_fl0(&path).unwrap(), b"snapshot bytes"); } #[test] fn rejects_corruption() { - assert!(decode(b"{}").is_err()); + assert!(decode_fl0_snapshot(b"{}").is_err()); } #[test] fn round_trips() { - let document = FlowDocument::new(); - let restored = decode(&encode(&document).unwrap()).unwrap(); - assert_eq!(document.projection(), restored.projection()); + let decoded = decode_fl0_snapshot(&encode_fl0_snapshot(b"loro snapshot")).unwrap(); + assert_eq!(decoded, b"loro snapshot"); } } diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 83525e74..eb7a84bd 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,9 +1,12 @@ use std::collections::HashSet; use std::path::PathBuf; +use std::sync::Arc; + +use flowstate_collab::flow::{FlowDocHandle, FlowStreamItem, FlowUndoMeta}; +use flowstate_flow::intents::{CellPlacement, CellSeed}; use flowstate_flow::{ - AnnotationOriginator, BoardPoint, CellId, FlowCommitResult, FlowDocument, FlowDropIntent, FlowFrontier, FlowProjection, - FlowProjectionSnapshot, FlowRuntimeEvent, FlowTransactionId, FlowUpdateBytes, RelativePosition, SheetId, VersionVector, + AnnotationOriginator, BoardPoint, CellId, FlowBoardProjection, FlowDropIntent, FlowIntent, RelativePosition, SheetId, board_ops, }; use gpui::{ AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, KeyUpEvent, @@ -59,7 +62,13 @@ struct PanDragState { } pub struct FlowEditor { - document: FlowDocument, + /// The ONE local write path (invariant 5): identical handle for solo and + /// collaborative tabs; the board copy advances only via the ordered stream. + handle: Arc, + board: FlowBoardProjection, + /// Render cache for INACTIVE cells' rich-text previews (materialized on + /// demand, dropped on any board change / remote sync). + cell_documents: std::collections::HashMap, path: Option, dirty: bool, active_sheet: Option, @@ -92,16 +101,21 @@ pub struct FlowEditor { } impl FlowEditor { - pub fn new_with_path(document: FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { - let active_sheet = document.projection().sheets.first().map(|sheet| sheet.id); - let collapsed_outline_items = document - .projection() + pub fn new_with_path(handle: Arc, path: Option, _window: &mut Window, cx: &mut Context) -> Self { + let board = handle.board_projection().unwrap_or(FlowBoardProjection { + format: flowstate_flow::FlowFormat::policy_debate(), + sheets: Vec::new(), + }); + let active_sheet = board.sheets.first().map(|sheet| sheet.id); + let collapsed_outline_items = board .sheets .iter() .flat_map(|sheet| std::iter::once(sheet.id).chain(sheet.cells.iter().map(|cell| cell.id))) .collect(); Self { - document, + handle, + board, + cell_documents: std::collections::HashMap::new(), path, dirty: false, active_sheet, @@ -135,49 +149,75 @@ impl FlowEditor { } pub fn blank(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(FlowDocument::new(), None, window, cx) - } - - pub fn document(&self) -> &FlowDocument { - &self.document + let (handle, _gate) = + FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()).expect("fresh flow document"); + Self::new_with_path(handle, None, window, cx) } - pub fn version_vector(&self) -> VersionVector { - self.document.version_vector() + pub fn handle(&self) -> &Arc { + &self.handle } - pub fn collaboration_projection_snapshot(&self) -> FlowProjectionSnapshot { - self.document.projection_snapshot() + pub fn board(&self) -> &FlowBoardProjection { + &self.board } - pub fn collaboration_frontier(&self) -> FlowFrontier { - self.document.frontier() - } - - pub fn collaboration_updates_since(&self, version: &VersionVector) -> anyhow::Result { - self.document.export_updates_for(version) + /// Drain the ordered board stream and adopt the latest Replace — the ONE + /// way this editor's board copy advances. Local intents call it right after + /// `apply`; the collaboration session calls it as a pump when remote + /// batches land; solo tabs need no pump. + pub fn sync_board_from_handle(&mut self, cx: &mut Context) { + let Ok(items) = self.handle.drain_board_stream() else { + return; + }; + let Some(FlowStreamItem::Board(board)) = items.into_iter().next_back() else { + return; + }; + self.board = *board; + self.cell_documents.clear(); + self.prune_dead_cell_state(cx); + if let Some(active) = self.active_sheet + && self.board.sheet(active).is_none() + { + self.active_sheet = self.board.sheets.first().map(|sheet| sheet.id); + cx.emit(FlowEditorEvent::ActiveSheetChanged(self.active_sheet)); + } + if let Some(active) = self.active_cell + && self.board.cell(active).is_none() + { + self.active_cell = None; + cx.emit(FlowEditorEvent::ActiveCellChanged(None)); + } + cx.notify(); } - pub fn import_collaboration_updates(&mut self, bytes: &[u8], cx: &mut Context) -> anyhow::Result> { - let events = self.document.import_remote_update(bytes)?; - self.sync_cell_editors(cx); - self.changed(self.active_cell, cx); - Ok(events) + /// Apply one flow intent through the handle and integrate the outcome. + fn apply_intent(&mut self, intent: FlowIntent, cx: &mut Context) -> bool { + match self.handle.apply(intent) { + Ok(outcome) => { + self.sync_board_from_handle(cx); + if outcome.changed { + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + outcome.changed + }, + Err(rejection) => { + tracing::warn!(%rejection, "flow intent rejected"); + false + }, + } } - pub fn apply_collaboration_transaction( - &mut self, - transaction_id: FlowTransactionId, - base_frontier: &[u8], - change: impl FnOnce(&mut FlowProjection) -> anyhow::Result<()>, - cx: &mut Context, - ) -> anyhow::Result { - let result = self - .document - .apply_projection_transaction(transaction_id, base_frontier, change)?; - self.sync_cell_editors(cx); - self.changed(self.active_cell, cx); - Ok(result) + /// Update the undo context riding future commits (focus restoration). + fn refresh_undo_context(&self) { + self.handle.set_undo_context(FlowUndoMeta { + active_sheet: self.active_sheet, + focused_cell: self.active_cell, + head_cursor: None, + anchor_cursor: None, + }); } pub fn active_sheet(&self) -> Option { @@ -226,40 +266,29 @@ impl FlowEditor { } pub fn activate_sheet(&mut self, sheet_id: SheetId, cx: &mut Context) { - if self - .document - .projection() - .sheets - .iter() - .any(|sheet| sheet.id == sheet_id) - { + if self.board.sheet(sheet_id).is_some() { self.active_sheet = Some(sheet_id); self.active_cell = None; + self.refresh_undo_context(); cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet_id))); cx.notify(); } } pub fn activate_cell(&mut self, cell_id: CellId, cx: &mut Context) { - let sheet_id = self - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.cells.iter().any(|cell| cell.id == cell_id)) - .map(|sheet| sheet.id); + let sheet_id = self.board.cell(cell_id).map(|(sheet, _)| sheet.id); if let Some(sheet_id) = sheet_id { - if self - .document - .ensure_cell_editable_projection(sheet_id, cell_id) - .is_ok_and(|changed| changed) - { - self.dirty = true; - cx.emit(FlowEditorEvent::Changed); - } + self.apply_intent( + FlowIntent::EnsureCellEditable { + sheet_id, + cell_id, + }, + cx, + ); self.ensure_cell_editor(cell_id, cx); self.active_sheet = Some(sheet_id); self.active_cell = Some(cell_id); + self.refresh_undo_context(); self.scroll_cell_into_view(cell_id); cx.emit(FlowEditorEvent::ActiveCellChanged(Some(cell_id))); cx.notify(); @@ -281,69 +310,50 @@ impl FlowEditor { let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - if let Ok(id) = self.document.add_response(sheet, cell) { - self.collapsed_outline_items.insert(id); - self.ensure_cell_editor(id, cx); - self.changed(Some(id), cx); - } + self.add_cell_with_placement(sheet, CellPlacement::ResponseTo { parent: cell }, cx); } pub fn add_first_response_to(&mut self, cell: CellId, cx: &mut Context) { let Some(sheet) = self.active_sheet else { return; }; - if let Ok(id) = self.document.add_first_response(sheet, cell) { - self.collapsed_outline_items.insert(id); - self.ensure_cell_editor(id, cx); - self.changed(Some(id), cx); + self.add_cell_with_placement(sheet, CellPlacement::FirstResponseTo { parent: cell }, cx); + } + + /// The one AddCell path behind every add_* entry point. + fn add_cell_with_placement(&mut self, sheet_id: SheetId, placement: CellPlacement, cx: &mut Context) { + let cell_id = uuid::Uuid::new_v4(); + if self.apply_intent( + FlowIntent::AddCell { + sheet_id, + cell_id, + placement, + seed: CellSeed::Empty, + }, + cx, + ) { + self.collapsed_outline_items.insert(cell_id); + self.ensure_cell_editor(cell_id, cx); + self.changed(Some(cell_id), cx); } } pub fn active_cell_is_struck(&self) -> bool { self - .active_sheet - .zip(self.active_cell) - .and_then(|(sheet, cell)| { - self - .document - .projection() - .sheets - .iter() - .find(|candidate| candidate.id == sheet)? - .cells - .iter() - .find(|candidate| candidate.id == cell) - }) - .and_then(|cell| cell.document().ok()) - .is_some_and(|document| { - document - .paragraphs - .iter() - .flat_map(|paragraph| ¶graph.runs) - .all(|run| run.styles.strikethrough) - }) + .active_cell + .and_then(|cell| self.board.cell(cell)) + .is_some_and(|(_, cell)| cell.summary.struck) } pub fn add_first_argument(&mut self, cx: &mut Context) { - let Some(sheet) = self.active_sheet else { - return; - }; - if let Ok(id) = self.document.add_orphan_at_column_top(sheet, 0) { - self.collapsed_outline_items.insert(id); - self.ensure_cell_editor(id, cx); - self.changed(Some(id), cx); - } + self.add_orphan_at_column_top(0, cx); } pub fn add_orphan_at_column_top(&mut self, column_index: usize, cx: &mut Context) { let Some(sheet) = self.active_sheet else { return; }; - if let Ok(id) = self.document.add_orphan_at_column_top(sheet, column_index) { - self.collapsed_outline_items.insert(id); - self.ensure_cell_editor(id, cx); - self.changed(Some(id), cx); - } + self.add_cell_with_placement(sheet, CellPlacement::ColumnTop { column_index }, cx); } pub fn create_sheet(&mut self, cx: &mut Context) { @@ -351,23 +361,25 @@ impl FlowEditor { } pub fn create_sheet_of_type(&mut self, sheet_type_index: usize, cx: &mut Context) { - let Some(sheet_type) = self - .document - .projection() - .format - .sheet_types - .get(sheet_type_index) - else { + let Some(sheet_type) = self.board.format.sheet_types.get(sheet_type_index) else { return; }; - let name = format!("Sheet {}", self.document.projection().sheets.len() + 1); - if let Ok(id) = self.document.create_sheet(name, sheet_type.id) { - self.collapsed_outline_items.insert(id); - self.active_sheet = Some(id); + let sheet_type_id = sheet_type.id; + let name = format!("Sheet {}", self.board.sheets.len() + 1); + let sheet_id = uuid::Uuid::new_v4(); + if self.apply_intent( + FlowIntent::CreateSheet { + sheet_id, + name, + sheet_type_id, + }, + cx, + ) { + self.collapsed_outline_items.insert(sheet_id); + self.active_sheet = Some(sheet_id); self.active_cell = None; - self.dirty = true; - cx.emit(FlowEditorEvent::Changed); - cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(id))); + self.refresh_undo_context(); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet_id))); cx.notify(); } } @@ -376,7 +388,13 @@ impl FlowEditor { let Some(sheet) = self.active_sheet else { return; }; - if self.document.rename_sheet(sheet, name).is_ok() { + if self.apply_intent( + FlowIntent::RenameSheet { + sheet_id: sheet, + name: name.into(), + }, + cx, + ) { self.changed(self.active_cell, cx); } } @@ -385,17 +403,11 @@ impl FlowEditor { let Some(sheet) = self.active_sheet else { return; }; - if self.document.delete_sheet(sheet).is_ok() { + if self.apply_intent(FlowIntent::DeleteSheet { sheet_id: sheet }, cx) { self.collapsed_outline_items.remove(&sheet); - self.active_sheet = self - .document - .projection() - .sheets - .first() - .map(|sheet| sheet.id); + self.active_sheet = self.board.sheets.first().map(|sheet| sheet.id); self.active_cell = None; - self.dirty = true; - cx.emit(FlowEditorEvent::Changed); + self.refresh_undo_context(); cx.emit(FlowEditorEvent::ActiveSheetChanged(self.active_sheet)); cx.emit(FlowEditorEvent::ActiveCellChanged(None)); cx.notify(); @@ -406,19 +418,21 @@ impl FlowEditor { let Some(sheet) = self.active_sheet else { return; }; - let Some(index) = self - .document - .projection() - .sheets - .iter() - .position(|candidate| candidate.id == sheet) - else { + let Some(index) = self.board.sheets.iter().position(|candidate| candidate.id == sheet) else { return; }; let target = index .saturating_add_signed(direction) - .min(self.document.projection().sheets.len().saturating_sub(1)); - if target != index && self.document.move_sheet(sheet, target).is_ok() { + .min(self.board.sheets.len().saturating_sub(1)); + if target != index + && self.apply_intent( + FlowIntent::MoveSheet { + sheet_id: sheet, + target_index: target, + }, + cx, + ) + { self.changed(self.active_cell, cx); } } @@ -459,17 +473,10 @@ impl FlowEditor { fn cell_text_color(&self, cell_id: CellId, cx: &App) -> gpui::Hsla { self - .document - .projection() - .sheets - .iter() - .find_map(|sheet| { - let cell = sheet.cells.iter().find(|cell| cell.id == cell_id)?; - let definition = self - .document - .projection() - .format - .sheet_type(sheet.sheet_type_id)?; + .board + .cell(cell_id) + .and_then(|(sheet, cell)| { + let definition = self.board.format.sheet_type(sheet.sheet_type_id)?; let column = definition .columns .iter() @@ -520,40 +527,29 @@ impl FlowEditor { let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - if let Ok(id) = self.document.add_sibling(sheet, cell, position) { - self.collapsed_outline_items.insert(id); - self.ensure_cell_editor(id, cx); - self.changed(Some(id), cx); - } + self.add_cell_with_placement(sheet, CellPlacement::Sibling { of: cell, position }, cx); } pub fn active_cell_is_empty(&self) -> bool { self - .active_sheet - .zip(self.active_cell) - .and_then(|(sheet_id, cell_id)| { - self - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id)? - .cells - .iter() - .find(|cell| cell.id == cell_id) - }) - .is_some_and(|cell| cell.is_empty().unwrap_or(false)) + .active_cell + .and_then(|cell| self.board.cell(cell)) + .is_some_and(|(_, cell)| cell.summary.is_empty) } pub fn delete_selected(&mut self, window: &mut Window, cx: &mut Context) { let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - let fallback = self.document.deletion_fallback(sheet, cell); - if self.document.delete_cell(sheet, cell).is_ok() { - self.cell_editors.remove(&cell); - self.cell_editor_themes.remove(&cell); - self.cell_editor_subscriptions.remove(&cell); + let fallback = board_ops::deletion_fallback(&self.board, sheet, cell); + if self.apply_intent( + FlowIntent::DeleteCell { + sheet_id: sheet, + cell_id: cell, + }, + cx, + ) { + self.drop_cell_editor(cell); if let Some(fallback) = fallback { self.ensure_cell_editor(fallback, cx); self.changed(Some(fallback), cx); @@ -571,23 +567,36 @@ impl FlowEditor { let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - if self.document.strike_cell(sheet, cell).is_ok() { - self.sync_cell_editors(cx); + let struck = self + .board + .cell(cell) + .is_some_and(|(_, cell)| cell.summary.struck); + if self.apply_intent( + FlowIntent::SetCellStruck { + sheet_id: sheet, + cell_id: cell, + struck: !struck, + }, + cx, + ) { self.changed(Some(cell), cx); } } pub fn can_undo(&self) -> bool { - self.document.can_undo() + self.handle.can_undo().unwrap_or(false) } pub fn can_redo(&self) -> bool { - self.document.can_redo() + self.handle.can_redo().unwrap_or(false) } pub fn undo(&mut self, cx: &mut Context) { - if self.document.undo().is_ok_and(|changed| changed) { - self.sync_cell_editors(cx); + if let Ok(outcome) = self.handle.undo() + && outcome.applied + { + self.sync_board_from_handle(cx); + self.restore_undo_focus(outcome.meta, cx); self.dirty = true; cx.emit(FlowEditorEvent::Changed); cx.notify(); @@ -595,14 +604,72 @@ impl FlowEditor { } pub fn redo(&mut self, cx: &mut Context) { - if self.document.redo().is_ok_and(|changed| changed) { - self.sync_cell_editors(cx); + if let Ok(outcome) = self.handle.redo() + && outcome.applied + { + self.sync_board_from_handle(cx); + self.restore_undo_focus(outcome.meta, cx); self.dirty = true; cx.emit(FlowEditorEvent::Changed); cx.notify(); } } + /// `FlowUndoMeta` focus restoration: bring back the sheet/cell the undone + /// commit was made under (cell-text selection restores through the cell's + /// own authority). + fn restore_undo_focus(&mut self, meta: Option, cx: &mut Context) { + let Some(meta) = meta else { + return; + }; + if let Some(sheet) = meta.active_sheet + && self.board.sheet(sheet).is_some() + && self.active_sheet != Some(sheet) + { + self.active_sheet = Some(sheet); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet))); + } + if let Some(cell) = meta.focused_cell + && self.board.cell(cell).is_some() + && self.active_cell != Some(cell) + { + self.ensure_cell_editor(cell, cx); + self.active_cell = Some(cell); + cx.emit(FlowEditorEvent::ActiveCellChanged(Some(cell))); + } + } + + /// Drop every per-cell UI artifact for cells no longer on the board, and + /// release their runtime streams. + fn prune_dead_cell_state(&mut self, cx: &mut Context) { + let live: HashSet = self + .board + .sheets + .iter() + .flat_map(|sheet| sheet.cells.iter().map(|cell| cell.id)) + .collect(); + let dead: Vec = self + .cell_editors + .keys() + .copied() + .filter(|id| !live.contains(id)) + .collect(); + for id in dead { + self.drop_cell_editor(id); + } + self.cell_bounds.retain(|id, _| live.contains(id)); + self.cell_measurements.retain(|id, _| live.contains(id)); + self.cell_documents.retain(|id, _| live.contains(id)); + let _ = cx; + } + + fn drop_cell_editor(&mut self, cell: CellId) { + self.cell_editors.remove(&cell); + self.cell_editor_themes.remove(&cell); + self.cell_editor_subscriptions.remove(&cell); + let _ = self.handle.close_cell(cell); + } + fn changed(&mut self, active_cell: Option, cx: &mut Context) { self.active_cell = active_cell; self.dirty = true; @@ -626,11 +693,16 @@ impl FlowEditor { } fn save_to_path(&mut self, path: PathBuf, cx: &mut Context) -> Task> { - let document = self.document.clone(); + // Fork under the gate (brief), encode + write on the background executor + // (the I-9a long-export rule at editor scale). + let handle = self.handle.clone(); cx.spawn(async move |editor, cx| { let write_result = cx .background_executor() - .spawn(async move { flowstate_flow::save_flow_document(&path, &document).map_err(std::io::Error::other) }) + .spawn(async move { + let snapshot = handle.snapshot().map_err(std::io::Error::other)?; + flowstate_flow::write_fl0(&path, &snapshot).map_err(std::io::Error::other) + }) .await; if write_result.is_ok() { let _ = editor.update(cx, |editor, cx| { @@ -646,14 +718,27 @@ impl FlowEditor { pub fn resolve_pending(&mut self, _cx: &mut Context) {} - fn render_sheet(&self, sheet_id: SheetId, cx: &mut Context) -> AnyElement { - let Some(real_sheet) = self - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - else { + fn render_sheet(&mut self, sheet_id: SheetId, cx: &mut Context) -> AnyElement { + // Prefill the inactive-cell rich-text preview cache OUTSIDE the render + // borrow (materialize-on-miss; dropped on any board change). + let missing: Vec = self + .board + .sheet(sheet_id) + .map(|sheet| { + sheet + .cells + .iter() + .map(|cell| cell.id) + .filter(|id| !self.cell_documents.contains_key(id)) + .collect() + }) + .unwrap_or_default(); + for id in missing { + if let Ok(document) = self.handle.cell_preview(id) { + self.cell_documents.insert(id, document); + } + } + let Some(real_sheet) = self.board.sheet(sheet_id) else { return div().child("Select a sheet").into_any_element(); }; // During a drag the layout stays stable (no reflow) so drop locations don't move under the pointer: @@ -661,12 +746,7 @@ impl FlowEditor { // shown by a directional accent on the target cell. The real subtree move still happens on drop. let sheet = real_sheet; let drop_target = self.drag_drop_target(real_sheet); - let Some(definition) = self - .document - .projection() - .format - .sheet_type(sheet.sheet_type_id) - else { + let Some(definition) = self.board.format.sheet_type(sheet.sheet_type_id) else { return div().child("Invalid sheet type").into_any_element(); }; let active = self.active_cell; @@ -872,12 +952,9 @@ impl FlowEditor { let cell_top = layout.top + shift; let spacer_height = px((cell_top - previous_bottom).max(0.0)); previous_bottom = cell_top + layout.height; - let label: SharedString = cell - .summary_text() - .unwrap_or_else(|_| "Invalid rich text".into()) - .into(); - let mut uses_summary_projection = cell.uses_summary_projection().unwrap_or(false); - let mut rendered_document = cell.document().ok(); + let label: SharedString = cell.summary.summary_text.to_string().into(); + let mut uses_summary_projection = cell.summary.uses_summary_projection; + let mut rendered_document = self.cell_documents.get(&id).cloned(); if let Some(document) = rendered_document.as_mut() { if !uses_summary_projection { let restyled = { diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs index 32d2861a..76e4eac2 100644 --- a/crates/flowstate/src/flow/editor/annotation.rs +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -39,11 +39,14 @@ impl FlowEditor { } pub fn clear_annotations(&mut self, cx: &mut Context) { - if self - .document - .clear_all_annotations(&self.local_annotation_originator) - .is_ok() - { + let originator = self.local_annotation_originator.clone(); + if self.apply_intent( + flowstate_flow::FlowIntent::ClearAnnotations { + sheet_id: None, + originator, + }, + cx, + ) { self.changed(self.active_cell, cx); } } @@ -117,20 +120,13 @@ impl FlowEditor { }, bbox, }; - if self.document.add_annotation(sheet_id, stroke).is_ok() { + if self.apply_intent(flowstate_flow::FlowIntent::AddAnnotation { sheet_id, stroke }, cx) { self.changed(self.active_cell, cx); } } fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { - let Some(sheet) = self.active_sheet.and_then(|sheet_id| { - self - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - }) else { + let Some(sheet) = self.active_sheet.and_then(|sheet_id| self.board.sheet(sheet_id)) else { return; }; let radius = 10.0; @@ -149,13 +145,19 @@ impl FlowEditor { .any(|segment| segment_distance(point, segment[0], segment[1]) <= radius) }) .map(|stroke| stroke.id); - if let Some(stroke_id) = touched - && self - .document - .delete_annotation(sheet.id, stroke_id, &self.local_annotation_originator) - .is_ok_and(|removed| removed) - { - self.changed(self.active_cell, cx); + let sheet_id = sheet.id; + if let Some(stroke_id) = touched { + let originator = self.local_annotation_originator.clone(); + if self.apply_intent( + flowstate_flow::FlowIntent::DeleteAnnotation { + sheet_id, + stroke_id, + originator, + }, + cx, + ) { + self.changed(self.active_cell, cx); + } } } } diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs index c29ca1c2..c16e72ce 100644 --- a/crates/flowstate/src/flow/editor/cell_editing.rs +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -1,31 +1,33 @@ +//! Per-cell editor wiring (flow spec Part C, editor rewiring). +//! +//! Each open cell's `RichTextEditor` is driven by a real +//! [`flowstate_collab::flow::FlowCellAuthority`] through +//! `RichTextEditor::set_write_authority` — the identical injection the .db8 +//! body editor uses (invariant 5). The record-era observer loop (re-encode +//! whole cell → `replace_cell_document` on every editor change) is GONE: +//! keystrokes commit through the gated intent path, and this flow editor +//! only reacts to the editor's notifications to refresh its board copy. + use flowstate_flow::CellId; use gpui::{AppContext as _, Context}; use gpui_component::ActiveTheme as _; use crate::{app_settings::load_document_theme, flow::cell_theme::apply_flow_cell_theme, rich_text_element::RichTextEditor}; -use super::{FlowEditor, FlowEditorEvent}; +use super::FlowEditor; impl FlowEditor { pub(super) fn ensure_cell_editor(&mut self, cell_id: CellId, cx: &mut Context) { if self.cell_editors.contains_key(&cell_id) { return; } - let Some((sheet_id, mut document, uses_summary_projection)) = self.document.projection().sheets.iter().find_map(|sheet| { - sheet - .cells - .iter() - .find(|cell| cell.id == cell_id) - .and_then(|cell| { - cell - .document() - .ok() - .map(|document| (document, cell.uses_summary_projection().unwrap_or(false))) - }) - .map(|(document, uses_summary_projection)| (sheet.id, document, uses_summary_projection)) - }) else { + let Ok(mut document) = self.handle.open_cell(cell_id) else { return; }; + let uses_summary_projection = self + .board + .cell(cell_id) + .is_some_and(|(_, cell)| cell.summary.uses_summary_projection); let text_color = self.cell_text_color(cell_id, cx); apply_flow_cell_theme( &mut document, @@ -34,8 +36,9 @@ impl FlowEditor { cx.theme().background, self.board_zoom(), ); + let authority = self.handle.cell_authority(cell_id); let editor = cx.new(|cx| { - let mut editor = RichTextEditor::new_with_path(document, None, cx); + let mut editor = RichTextEditor::new_with_path(document.clone(), None, cx); editor.set_invisibility_mode(uses_summary_projection, cx); editor.update_config( |config| { @@ -46,43 +49,19 @@ impl FlowEditor { }, cx, ); + editor.set_write_authority(authority, document, cx); editor }); - let subscription = cx.observe(&editor, move |flow, editor, cx| { - let mut document = editor.read(cx).document().clone(); - let source_theme = flow - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) - .and_then(|cell| cell.document().ok()) - .map(|document| document.theme); - if let Some(source_theme) = source_theme { - document.theme = source_theme; - } - let Ok(bytes) = flowstate_flow::cell_db8_bytes(&document) else { - return; - }; - let unchanged = flow - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - .and_then(|sheet| sheet.cells.iter().find(|cell| cell.id == cell_id)) - .is_some_and(|cell| cell.document_bytes == bytes); - if unchanged { - return; - } - if flow - .document - .replace_cell_document(sheet_id, cell_id, &document) - .is_ok() - { - flow.dirty = true; - cx.emit(FlowEditorEvent::Changed); + // The editor commits through its authority; this subscription only keeps + // the BOARD side current (summary refreshes ride the board stream) and + // the dirty flag honest — caret-only notifies are ignored. + let subscription = cx.subscribe(&editor, move |flow, _editor, event: &crate::rich_text_element::EditorEvent, cx| { + if matches!(event, crate::rich_text_element::EditorEvent::Changed { .. }) { + flow.sync_board_from_handle(cx); + if !flow.dirty { + flow.dirty = true; + cx.emit(super::FlowEditorEvent::Changed); + } cx.notify(); } }); @@ -93,50 +72,11 @@ impl FlowEditor { self.cell_editor_subscriptions.insert(cell_id, subscription); } - pub(super) fn sync_cell_editors(&mut self, cx: &mut Context) { - let client_theme = load_document_theme(); - let cells: std::collections::HashMap<_, _> = self - .document - .projection() - .sheets - .iter() - .flat_map(|sheet| { - sheet - .cells - .iter() - .filter_map(|cell| cell.document().ok().map(|document| (cell.id, document))) - }) - .collect(); - self.cell_editors.retain(|id, _| cells.contains_key(id)); - self - .cell_editor_themes - .retain(|id, _| cells.contains_key(id)); - self - .cell_editor_subscriptions - .retain(|id, _| cells.contains_key(id)); - self.cell_bounds.retain(|id, _| cells.contains_key(id)); - self - .cell_measurements - .retain(|id, _| cells.contains_key(id)); - for (cell_id, editor) in &self.cell_editors { - if let Some(document) = cells.get(cell_id) { - let current = flowstate_flow::cell_db8_bytes(editor.read(cx).document()).ok(); - let mut themed_document = document.clone(); - let text_color = self.cell_text_color(*cell_id, cx); - apply_flow_cell_theme(&mut themed_document, &client_theme, text_color, cx.theme().background, self.board_zoom()); - let desired = flowstate_flow::cell_db8_bytes(&themed_document).ok(); - if current != desired { - editor.update(cx, |editor, cx| editor.replace_document_projection(themed_document, cx)); - self - .cell_editor_themes - .insert(*cell_id, (text_color, cx.theme().background, self.board_zoom().to_bits())); - } - } - } - if let Some(active) = self.active_cell - && cells.contains_key(&active) - { - self.ensure_cell_editor(active, cx); + /// Pump remote projection changes into every open cell editor (the + /// collaboration session calls this after imports; solo tabs never need it). + pub fn sync_cell_editors_from_authority(&mut self, cx: &mut Context) { + for editor in self.cell_editors.values() { + editor.update(cx, |editor, cx| editor.sync_projection_from_authority(cx)); } } @@ -144,7 +84,7 @@ impl FlowEditor { let Some(cell_id) = self.active_cell else { return; }; - let Some(editor) = self.cell_editors.get(&cell_id) else { + let Some(editor) = self.cell_editors.get(&cell_id).cloned() else { return; }; let text_color = self.cell_text_color(cell_id, cx); @@ -152,15 +92,7 @@ impl FlowEditor { if self.cell_editor_themes.get(&cell_id) == Some(&signature) { return; } - let Some(mut document) = self - .document - .projection() - .sheets - .iter() - .flat_map(|sheet| &sheet.cells) - .find(|cell| cell.id == cell_id) - .and_then(|cell| cell.document().ok()) - else { + let Ok(mut document) = self.handle.cell_preview(cell_id) else { return; }; apply_flow_cell_theme( @@ -170,7 +102,7 @@ impl FlowEditor { cx.theme().background, self.board_zoom(), ); - editor.update(cx, |editor, cx| editor.replace_document_projection(document, cx)); + editor.update(cx, |editor, cx| editor.install_canonical_projection(document, cx)); self.cell_editor_themes.insert(cell_id, signature); } } diff --git a/crates/flowstate/src/flow/editor/drag_drop.rs b/crates/flowstate/src/flow/editor/drag_drop.rs index 59795957..77e6b372 100644 --- a/crates/flowstate/src/flow/editor/drag_drop.rs +++ b/crates/flowstate/src/flow/editor/drag_drop.rs @@ -1,4 +1,4 @@ -use flowstate_flow::{CellId, FlowDropIntent, Sheet}; +use flowstate_flow::{CellId, FlowDropIntent, Sheet, board_ops}; use gpui::{Context, IntoElement, Pixels, Point, Render, SharedString, Window, div, point, prelude::*, px}; use gpui_component::ActiveTheme as _; use gpui_component::PixelsExt as _; @@ -61,9 +61,7 @@ impl FlowEditor { let dragging = self.dragging_cell?; let intent = self.pending_cell_drop?; let sheet_id = self.active_sheet?; - self - .document - .preview_move_cell_subtree(sheet_id, dragging, intent)?; + board_ops::preview_move_cell_subtree(&self.board, sheet_id, dragging, intent)?; match intent { FlowDropIntent::BeforeSibling(target) => Some((target, DropEdge::Before)), FlowDropIntent::AfterSibling(target) => Some((target, DropEdge::After)), @@ -92,8 +90,7 @@ impl FlowEditor { Some((cell.id, DropEdge::Before)) } else { let column = self - .document - .projection() + .board .format .sheet_type(sheet.sheet_type_id)? .columns @@ -124,12 +121,8 @@ impl FlowEditor { /// the (empty) `child_column` beside it can adopt it. The dragged cell is skipped so it can't parent /// to itself. fn childless_parent_at_row(&self, parent_column: usize, child_column: usize, y: gpui::Pixels) -> Option { - let projection = self.document.projection(); - let sheet = projection - .sheets - .iter() - .find(|sheet| Some(sheet.id) == self.active_sheet)?; - let definition = projection.format.sheet_type(sheet.sheet_type_id)?; + let sheet = self.active_sheet.and_then(|id| self.board.sheet(id))?; + let definition = self.board.format.sheet_type(sheet.sheet_type_id)?; let parent_column = definition.columns.get(parent_column)?.id; let _ = definition.columns.get(child_column)?; sheet @@ -162,21 +155,10 @@ impl FlowEditor { let Some(sheet_id) = self.active_sheet else { return; }; - let Some(sheet) = self - .document - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id) - else { + let Some(sheet) = self.board.sheet(sheet_id) else { return; }; - let Some(definition) = self - .document - .projection() - .format - .sheet_type(sheet.sheet_type_id) - else { + let Some(definition) = self.board.format.sheet_type(sheet.sheet_type_id) else { return; }; let Some(column) = definition.columns.get(column_index) else { @@ -277,10 +259,14 @@ impl FlowEditor { self.dragging_cell = None; self.drag_autoscroll = None; let committed = match (destination, self.active_sheet) { - (Some(destination), Some(sheet_id)) => self - .document - .move_cell_subtree(sheet_id, dragged, destination) - .is_ok(), + (Some(destination), Some(sheet_id)) => self.apply_intent( + flowstate_flow::FlowIntent::MoveCellSubtree { + sheet_id, + cell_id: dragged, + drop: destination, + }, + cx, + ), _ => false, }; self.finish_drag_log(destination, committed); diff --git a/crates/flowstate/src/flow/editor/layout.rs b/crates/flowstate/src/flow/editor/layout.rs index 2392d40f..72a35f46 100644 --- a/crates/flowstate/src/flow/editor/layout.rs +++ b/crates/flowstate/src/flow/editor/layout.rs @@ -98,7 +98,7 @@ fn measured_cell_height(cell: &Cell, measurements: &HashMap f32 { - let text = cell.summary_text().unwrap_or_default(); + let text = cell.summary.summary_text.to_string(); let lines = text .lines() .map(|line| line.chars().count().div_ceil(34).max(1)) @@ -113,9 +113,12 @@ mod tests { use flowstate_flow::{Cell, ColumnId}; fn cell(parent_id: Option) -> Cell { - let mut cell = Cell::plain(ColumnId::new_v4()).unwrap(); - cell.parent_id = parent_id; - cell + Cell { + id: CellId::new_v4(), + column_id: ColumnId::new_v4(), + parent_id, + summary: flowstate_flow::CellSummary::default(), + } } #[test] diff --git a/crates/flowstate/src/flow/editor/telemetry.rs b/crates/flowstate/src/flow/editor/telemetry.rs index ef7f2c89..4d992b1d 100644 --- a/crates/flowstate/src/flow/editor/telemetry.rs +++ b/crates/flowstate/src/flow/editor/telemetry.rs @@ -83,15 +83,7 @@ impl FlowEditor { let dragged = self.cell_label(dragged); let sheet = self .active_sheet - .and_then(|id| { - self - .document() - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == id) - .map(|sheet| sheet.name.clone()) - }) + .and_then(|id| self.board().sheet(id).map(|sheet| sheet.name.clone())) .unwrap_or_default(); self.drag_log_counter += 1; self.drag_log = Some(DragLogSession { @@ -180,13 +172,9 @@ impl FlowEditor { fn cell_label(&self, id: CellId) -> String { self - .document() - .projection() - .sheets - .iter() - .find_map(|sheet| sheet.cells.iter().find(|cell| cell.id == id)) - .and_then(|cell| cell.summary_text().ok()) - .map(|text| text.lines().next().unwrap_or_default().trim().to_string()) + .board() + .cell(id) + .map(|(_, cell)| cell.summary.summary_text.lines().next().unwrap_or_default().trim().to_string()) .filter(|label| !label.is_empty()) .unwrap_or_else(|| format!("cell:{}", &id.to_string()[..8])) } @@ -209,22 +197,15 @@ impl FlowEditor { .active_sheet .zip(self.dragging_cell) .is_some_and(|(sheet, dragged)| { - self - .document() - .preview_move_cell_subtree(sheet, dragged, intent) - .is_some() + flowstate_flow::board_ops::preview_move_cell_subtree(self.board(), sheet, dragged, intent).is_some() }) } fn sheet_topology_snapshot(&self) -> Vec { - let projection = self.document().projection(); - let Some(sheet) = self - .active_sheet - .and_then(|id| projection.sheets.iter().find(|sheet| sheet.id == id)) - else { + let Some(sheet) = self.active_sheet.and_then(|id| self.board().sheet(id)) else { return Vec::new(); }; - let definition = projection.format.sheet_type(sheet.sheet_type_id); + let definition = self.board().format.sheet_type(sheet.sheet_type_id); sheet .cells .iter() diff --git a/crates/flowstate/src/flow/ribbon.rs b/crates/flowstate/src/flow/ribbon.rs index c649e4c1..a096e308 100644 --- a/crates/flowstate/src/flow/ribbon.rs +++ b/crates/flowstate/src/flow/ribbon.rs @@ -76,24 +76,14 @@ impl Render for FlowRibbon { let sheet_types: Vec<_> = self .editor .read(cx) - .document() - .projection() + .board() .format .sheet_types .iter() .map(|sheet_type| sheet_type.name.clone()) .collect(); let sheet_name_input = self.editor.read(cx).active_sheet().and_then(|sheet_id| { - let sheet_name = self - .editor - .read(cx) - .document() - .projection() - .sheets - .iter() - .find(|sheet| sheet.id == sheet_id)? - .name - .clone(); + let sheet_name = self.editor.read(cx).board().sheet(sheet_id)?.name.clone(); let editor = self.editor.clone(); let state = window.use_keyed_state(("flow-sheet-name-input", sheet_id.as_u128() as u64), cx, move |window, cx| { let input = cx.new(|cx| { diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 74018142..9e4a8533 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -163,6 +163,7 @@ impl Workspace { let mut this = Self { document_panels: Vec::new(), document_runtimes: FxHashMap::default(), // §perf: FxHash for trusted Uuid keys + flow_document_runtimes: FxHashMap::default(), pending_authority_panels: FxHashSet::default(), document_runtime_flush_pending: FxHashSet::default(), // §perf: FxHash for trusted Uuid keys flow_panels: Vec::new(), @@ -586,7 +587,10 @@ impl Workspace { } pub fn new_flow(&mut self, window: &mut Window, cx: &mut Context) { - self.add_flow_panel(flowstate_flow::FlowDocument::new(), None, window, cx); + let Ok((handle, gate)) = flowstate_collab::flow::FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()) else { + return; + }; + self.add_flow_panel(FlowRuntimeSource { handle, gate }, None, window, cx); } pub fn open_demo_document(&mut self, window: &mut Window, cx: &mut Context) { @@ -675,11 +679,11 @@ impl Workspace { }); }); }, - Ok(LoadedWorkspaceDocument::Flow { document, path }) => { + Ok(LoadedWorkspaceDocument::Flow { source, path }) => { let _ = window_handle.update(cx, |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| { workspace.record_recent_document(path.clone(), cx); - workspace.add_flow_panel(document, Some(path), window, cx); + workspace.add_flow_panel(source, Some(path), window, cx); }); }); }, @@ -797,10 +801,10 @@ impl Workspace { continue; } let loaded = if matches!(entry.kind, TemporaryWorkspaceSessionEntryKind::Flow) && is_flow_path(&entry.path) { - flowstate_flow::load_flow_document(&entry.path) + load_flow_runtime_source(&entry.path) .ok() - .map(|document| LoadedWorkspaceDocument::Flow { - document, + .map(|source| LoadedWorkspaceDocument::Flow { + source, path: entry.path, }) } else { @@ -833,8 +837,8 @@ impl Workspace { viewport_scrolls.push((panel_id, entry.viewport_paragraph)); panel_id }, - LoadedWorkspaceDocument::Flow { document, path } => { - let panel = self.create_flow_panel(document, Some(path), window, cx); + LoadedWorkspaceDocument::Flow { source, path } => { + let panel = self.create_flow_panel(source, Some(path), window, cx); panel.read(cx).id() }, }; @@ -1012,12 +1016,13 @@ impl Workspace { fn create_flow_panel( &mut self, - document: flowstate_flow::FlowDocument, + source: FlowRuntimeSource, path: Option, window: &mut Window, cx: &mut Context, ) -> Entity { - let editor = cx.new(|cx| FlowEditor::new_with_path(document, path.clone(), window, cx)); + let FlowRuntimeSource { handle, gate } = source; + let editor = cx.new(|cx| FlowEditor::new_with_path(handle, path.clone(), window, cx)); let workspace = cx.entity().downgrade(); let title = path .as_ref() @@ -1032,6 +1037,11 @@ impl Workspace { workspace.maybe_autosave_flow(id, editor.clone(), cx); }), )); + // The flow I/O service shares the editor's gate; collaboration and + // off-thread saves route through it (spec Part C). + if let Ok(io) = flowstate_collab::flow::FlowIoHandle::spawn(gate) { + self.flow_document_runtimes.insert(id, io); + } self.active_document_id = Some(id); self.active_editor = None; self.active_flow = Some(editor); @@ -1043,8 +1053,8 @@ impl Workspace { panel } - fn add_flow_panel(&mut self, document: flowstate_flow::FlowDocument, path: Option, window: &mut Window, cx: &mut Context) { - self.create_flow_panel(document, path, window, cx); + fn add_flow_panel(&mut self, source: FlowRuntimeSource, path: Option, window: &mut Window, cx: &mut Context) { + self.create_flow_panel(source, path, window, cx); self.persist_temporary_workspace_session(cx); cx.notify(); } @@ -1760,16 +1770,30 @@ enum LoadedWorkspaceDocument { title: Option, }, Flow { - document: flowstate_flow::FlowDocument, + source: FlowRuntimeSource, path: PathBuf, }, } +/// A flow write authority + its gate, ready to become a panel (the flow +/// mirror of the .db8 runtime attachment). +struct FlowRuntimeSource { + handle: std::sync::Arc, + gate: std::sync::Arc>, +} + +fn load_flow_runtime_source(path: &std::path::Path) -> Result { + let snapshot = flowstate_flow::read_fl0(path).map_err(|error| error.to_string())?; + let runtime = flowstate_collab::flow::FlowRuntime::from_snapshot(&snapshot).map_err(|error| error.to_string())?; + let (handle, gate) = flowstate_collab::flow::FlowDocHandle::new(runtime); + Ok(FlowRuntimeSource { handle, gate }) +} + #[hotpath::measure] fn load_workspace_document(path: PathBuf) -> Result { if is_flow_path(&path) { - let document = flowstate_flow::load_flow_document(&path).map_err(|error| error.to_string())?; - return Ok(LoadedWorkspaceDocument::Flow { document, path }); + let source = load_flow_runtime_source(&path)?; + return Ok(LoadedWorkspaceDocument::Flow { source, path }); } load_document_for_open(&path) .map(|loaded| LoadedWorkspaceDocument::Document { diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs index 27406a98..a1600d8a 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs @@ -50,3 +50,104 @@ fn find_keystroke_is_absorbed_without_panic(cx: &mut TestAppContext) { cx.simulate_keystrokes(h.window, "ctrl-f"); cx.run_until_parked(); } + +/// Build-order step 8 gate: solo flow edit/save/undo THROUGH the gated +/// authority path — sheet + cell structural intents, cell typing via the +/// per-cell `FlowCellAuthority`, `.fl0` v2 save round trip, drag commit via +/// `MoveCellSubtree`, and whole-doc undo. +#[gpui::test] +fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + + // Structural edits: sheet + two root cells + typing into the second. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + editor.create_sheet(cx); + editor.add_first_argument(cx); + editor.add_orphan_at_column_top(0, cx); + }); + }); + cx.run_until_parked(); + let (board, first, second) = h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + let cells: Vec<_> = board.sheets[0].cells.iter().map(|cell| cell.id).collect(); + (board.clone(), cells[1], cells[0]) + }); + assert_eq!(board.sheets.len(), 1); + assert_eq!(board.sheets[0].cells.len(), 2); + + // Cell typing through the cell authority (the write path an attached + // RichTextEditor drives). + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let authority = editor.handle().cell_authority(second); + let projection = editor.handle().open_cell(second).expect("open cell"); + use crate::rich_text_element::{InsertTextIntent, LocalIntent, LocalWriteAuthority, TextAnchor}; + let authority: std::sync::Arc = authority; + authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(projection.ids.paragraph_ids[0], 0), + text: "typed through the gate".into(), + style_override: None, + })) + .expect("cell typing commits"); + editor.sync_board_from_handle(cx); + }); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + let summary = &board.cell(second).expect("cell").1.summary; + assert_eq!(summary.summary_text.as_ref(), "typed through the gate"); + }); + + // Drag commit via MoveCellSubtree: `second` after `first`'s subtree. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let sheet = editor.board().sheets[0].id; + editor.activate_sheet(sheet, cx); + let moved = editor.handle().apply(flowstate_flow::FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: second, + drop: flowstate_flow::FlowDropIntent::AfterSibling(first), + }); + assert!(moved.is_ok_and(|outcome| outcome.changed), "drag commit through the gate"); + editor.sync_board_from_handle(cx); + }); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let order: Vec<_> = flow.read(cx).board().sheets[0].cells.iter().map(|cell| cell.id).collect(); + assert_eq!(order, vec![first, second]); + }); + + // Undo restores the pre-move order (whole-doc undo stack). + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.undo(cx)); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let order: Vec<_> = flow.read(cx).board().sheets[0].cells.iter().map(|cell| cell.id).collect(); + assert_eq!(order, vec![second, first], "undo reversed the move"); + }); + + // Save writes a v2 .fl0 that round-trips to the identical board. + let dir = std::env::temp_dir().join(format!("flowstate-headless-flow-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("gate.fl0"); + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.save_as(path.clone(), cx)).detach(); + }); + let flow_for_wait = flow.clone(); + h.wait_until(cx, "flow save to complete", move |_| path.exists()); + cx.run_until_parked(); + let snapshot = flowstate_flow::read_fl0(&dir.join("gate.fl0")).expect("read .fl0 v2"); + let reloaded = flowstate_collab::flow::FlowRuntime::from_snapshot(&snapshot).expect("reload"); + h.update(cx, |_, _, cx| { + assert_eq!(reloaded.board_ref(), flow_for_wait.read(cx).board(), "saved board round-trips"); + }); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index b05ff21c..73f59128 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -70,6 +70,7 @@ pub struct Workspace { document_panels: Vec>, // §perf: Uuid keys are locally generated and trusted; use FxHash to avoid SipHash overhead. document_runtimes: FxHashMap, + flow_document_runtimes: FxHashMap, document_runtime_flush_pending: FxHashSet, /// §act-three C (background open): panels painted read-only from a phase-V /// cached projection whose authority runtime has not yet attached (phase G). diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index c8c27797..751321b9 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -149,7 +149,7 @@ impl Workspace { let Some(editor) = self.active_flow.clone() else { return div().into_any_element(); }; - let projection = editor.read(cx).document().projection().clone(); + let projection = editor.read(cx).board().clone(); let active_sheet = editor.read(cx).active_sheet(); let active_cell = editor.read(cx).active_cell(); let mut rows = Vec::new(); @@ -279,10 +279,10 @@ fn append_flow_outline_cell_rows( } }) .collect(); - let label = cell - .summary_text() - .map(|text| if text.trim().is_empty() { "(empty)".into() } else { text }) - .unwrap_or_else(|_| "(invalid rich text)".into()); + let label = { + let text = cell.summary.summary_text.to_string(); + if text.trim().is_empty() { "(empty)".to_string() } else { text } + }; let activate_editor = editor.clone(); let toggle_editor = editor.clone(); let incoming_branch = cell.parent_id.map(|_| IncomingBranch { From 86139e910bc0f8e60dec5dad899386e8fcdf4462 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:28:25 +0000 Subject: [PATCH 21/24] =?UTF-8?q?flowstate:=20session/manager=20enum=20spl?= =?UTF-8?q?it=20=E2=80=94=20flow=20sessions=20host=20through=20the=20same?= =?UTF-8?q?=20CollabSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollabSession keeps one type; its document-facing half becomes two-arm enums (CollabEditor / JoinedAuthority / JoinedDocumentPayload) keyed by the ticket's DocumentKind. Hosting on an open flow tab reuses the tab's existing gated authority + FlowIoHandle (start_flow_session_for_panel); the join completion match dispatches RichText attachments vs create_flow_panel_from_attachment, which adopts the session's I/O service instead of spawning a duplicate on the same gate. Workspace glue: share button + collaboration dialog gate on flow tabs too, and the window-close prompt cascade scans flow_panels (collaboration_close_panels), so leaving/closing a live flow session prompts exactly like a .db8 one. Gates: full headless suite green (66 tests) including the .db8 collab_glue regression cases, plus new flow cases — host start registers a live phase + self roster entry, leave keeps the tab editable through the same authority, and the close-prompt cascade intercepts live flow sessions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate/src/collab/asset_transfer.rs | 5 +- crates/flowstate/src/collab/manager.rs | 72 +++- crates/flowstate/src/collab/mod.rs | 28 +- crates/flowstate/src/collab/presence_view.rs | 5 + crates/flowstate/src/collab/session.rs | 325 +++++++++++++++--- crates/flowstate/src/collab/session_io.rs | 38 +- .../flowstate/src/collab/session_presence.rs | 29 +- crates/flowstate/src/flow/editor.rs | 82 ++++- crates/flowstate/src/flow/mod.rs | 2 +- .../src/workspace/workspace/collab.rs | 100 +++++- .../src/workspace/workspace/collab_prompts.rs | 42 ++- .../src/workspace/workspace/documents.rs | 45 ++- .../workspace/headless_tests/collab_glue.rs | 80 +++++ .../src/workspace/workspace/render_top_bar.rs | 6 +- 14 files changed, 732 insertions(+), 127 deletions(-) diff --git a/crates/flowstate/src/collab/asset_transfer.rs b/crates/flowstate/src/collab/asset_transfer.rs index 1eee4813..ed139d15 100644 --- a/crates/flowstate/src/collab/asset_transfer.rs +++ b/crates/flowstate/src/collab/asset_transfer.rs @@ -13,7 +13,7 @@ pub(super) fn schedule_missing_assets( preferred_peer: Option, cx: &mut Context, ) { - let Some(editor) = session.editor.clone() else { + let Some(editor) = session.rich_text_editor() else { tracing::trace!(session = %session.session, preferred_peer = ?preferred_peer, "skipping collaboration asset scan because editor is missing"); return; }; @@ -23,6 +23,9 @@ pub(super) fn schedule_missing_assets( }; let session_id = session.session; cx.spawn(async move |session, cx| { + let Some(runtime) = runtime.as_rich_text().cloned() else { + return; + }; let result = runtime.asset_metadata().await; let _ = session.update(cx, |session, cx| match result { Ok(assets) => schedule_missing_assets_from_metadata(session, editor, preferred_peer, assets, cx), diff --git a/crates/flowstate/src/collab/manager.rs b/crates/flowstate/src/collab/manager.rs index f2ffe0ec..870e6ed0 100644 --- a/crates/flowstate/src/collab/manager.rs +++ b/crates/flowstate/src/collab/manager.rs @@ -45,6 +45,7 @@ pub struct CollabManager { discovery_publications: HashMap, discovery_paths: HashMap, discovery_titles: HashMap, + session_kinds: HashMap, pub(super) endpoint_online: bool, pub(super) event_pump_started: bool, } @@ -85,6 +86,7 @@ impl CollabManager { self.discovery_publications.clear(); self.discovery_paths.clear(); self.discovery_titles.clear(); + self.session_kinds.clear(); self.event_pump_started = false; self.endpoint_online = false; tracing::debug!("collaboration runtime shutdown state cleared"); @@ -123,8 +125,64 @@ impl CollabManager { self.discovery_paths.insert(session, path); } self.discovery_titles.insert(session, title.clone()); + self.session_kinds.insert(session, flowstate_collab::DocumentKind::RichText); let collab = CollabSession::from_local_runtime(session, panel_id, editor, title, document_io, commands.clone()); let direct_handler = collab.direct_handler(); + self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) + } + + /// Start a session on an open FLOW tab (spec Part C): identical wiring, flow + /// I/O handle + flow editor arm. + pub fn start_flow_session_for_panel( + &mut self, + panel_id: Uuid, + editor: Entity, + title: String, + flow_io: flowstate_collab::flow::FlowIoHandle, + cx: &mut Context, + ) -> Result + where + T: 'static, + { + if let Some(session) = self.session_by_panel.get(&panel_id).copied() { + tracing::debug!(%panel_id, %session, "collaboration session already exists for panel"); + return Ok(session); + } + let commands = self.ensure_runtime(cx)?; + let session = SessionId::new(); + let canonical_document_id = editor + .read(cx) + .handle() + .document_id() + .map(|id| id.as_u128()) + .unwrap_or_default(); + let document_path = editor.read(cx).document_path().cloned(); + self + .discovery_documents + .insert(session, document_fingerprint(canonical_document_id)); + tracing::info!(%panel_id, %session, title = %title, "starting local collaboration flow session"); + if let Some(path) = document_path { + self.discovery_paths.insert(session, path); + } + self.discovery_titles.insert(session, title.clone()); + self.session_kinds.insert(session, flowstate_collab::DocumentKind::Flow); + let collab = CollabSession::from_local_flow_runtime(session, panel_id, editor, title, flow_io, commands.clone()); + let direct_handler = collab.direct_handler(); + self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) + } + + fn finish_start_session( + &mut self, + session: SessionId, + panel_id: Uuid, + collab: CollabSession, + direct_handler: flowstate_collab::net::direct::DirectSessionHandler, + commands: flowstate_collab::net::runtime::CommandSender, + cx: &mut Context, + ) -> Result + where + T: 'static, + { let entity = cx.new(|_| collab); entity.update(cx, |session, cx| session.attach(cx)); @@ -203,7 +261,8 @@ impl CollabManager { .first() .map(|addr| addr.id) .ok_or_else(|| anyhow!("collaboration invite has no reachable participants"))?; - let collab = CollabSession::joining(session, ticket.title.clone(), commands.clone(), bootstrap.clone(), admission.clone()); + self.session_kinds.insert(session, ticket.document); + let collab = CollabSession::joining(session, ticket.title.clone(), commands.clone(), bootstrap.clone(), admission.clone(), ticket.document); let entity = cx.new(|_| collab); self.register_session(entity.clone(), cx); let neighbor_rx = entity.update(cx, |session, cx| session.prepare_join_neighbor_wait(cx)); @@ -232,7 +291,7 @@ impl CollabManager { &mut self, session_id: SessionId, panel_id: Uuid, - editor: Entity, + editor: super::session::CollabEditor, cx: &mut Context, ) -> Result<()> where @@ -540,13 +599,10 @@ impl CollabManager { .unwrap_or_else(|| "Shared document".into()); let identities = trusted_identity_keys_for_path(path); let document = self - .sessions_by_id + .session_kinds .get(&session) - .map_or(flowstate_collab::ticket::DocumentKind::RichText, |_| { - // Step 9 (session enum split) threads the live session's kind here; - // until then every standing-access session is a rich-text document. - flowstate_collab::ticket::DocumentKind::RichText - }); + .copied() + .unwrap_or(flowstate_collab::ticket::DocumentKind::RichText); if let Err(error) = runtime .commands .try_send(NetCommand::ConfigureStandingAccess { diff --git a/crates/flowstate/src/collab/mod.rs b/crates/flowstate/src/collab/mod.rs index 26c81357..1705d2eb 100644 --- a/crates/flowstate/src/collab/mod.rs +++ b/crates/flowstate/src/collab/mod.rs @@ -10,11 +10,11 @@ pub mod share_dialog; mod shutdown; pub mod status; -use std::{borrow::BorrowMut, sync::Arc}; +use std::borrow::BorrowMut; use anyhow::Result; use async_channel::Receiver; -use flowstate_collab::{SessionId, doc_io::DocIoHandle, local_write::LocalDocHandle, ticket::SessionTicket}; +use flowstate_collab::{SessionId, doc_io::DocIoHandle, ticket::SessionTicket}; use gpui::{App, BorrowAppContext, Context, Entity, ReadGlobal}; use uuid::Uuid; @@ -23,7 +23,8 @@ use crate::rich_text_element::RichTextEditor; pub use discovery_runtime::DiscoveryScanResult; pub use manager::{CollabManager, JoinRequest}; pub use session::{ - Attachment, CollabSession, Connectivity, DetachReason, JoinStage, JoinedDocument, SessionNotice, SessionPhase, SessionRosterEntry, + Attachment, CollabEditor, CollabSession, Connectivity, DetachReason, JoinStage, JoinedAuthority, JoinedDocument, JoinedDocumentPayload, + SessionNotice, SessionPhase, SessionRosterEntry, }; pub use shutdown::shutdown; @@ -47,12 +48,29 @@ where cx.update_default_global::(|manager, cx| manager.start_session_for_panel(panel_id, editor, title, io, cx)) } +/// Flow-tab variant of [`start_session_for_panel`] (spec Part C). +pub fn start_flow_session_for_panel( + panel_id: Uuid, + editor: Entity, + title: String, + io: flowstate_collab::flow::FlowIoHandle, + cx: &mut Context, +) -> Result +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.start_flow_session_for_panel(panel_id, editor, title, io, cx)) +} + /// JOIN handoff (Loro-first spec §3): take the document services the joining /// session constructed from the initial snapshot — the write authority for the /// editor and the I/O handle for saves/exports. One-shot: the write authority /// moves out of the session (the session is transport-only, invariant 5); /// subsequent calls return `None`. -pub fn take_joined_document_services_for_session(session_id: SessionId, cx: &mut Context) -> Option<(Arc, DocIoHandle)> +pub fn take_joined_document_services_for_session( + session_id: SessionId, + cx: &mut Context, +) -> Option<(session::JoinedAuthority, flowstate_collab::SyncIoHandle)> where T: 'static, { @@ -69,7 +87,7 @@ where cx.update_default_global::(|manager, cx| manager.join_session(ticket, cx)) } -pub fn attach_joined_session(session_id: SessionId, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> +pub fn attach_joined_session(session_id: SessionId, panel_id: Uuid, editor: session::CollabEditor, cx: &mut Context) -> Result<()> where T: 'static, { diff --git a/crates/flowstate/src/collab/presence_view.rs b/crates/flowstate/src/collab/presence_view.rs index 513a47a7..46d9124c 100644 --- a/crates/flowstate/src/collab/presence_view.rs +++ b/crates/flowstate/src/collab/presence_view.rs @@ -12,6 +12,11 @@ pub fn collaboration_recovery_path(session: SessionId, title: &str) -> PathBuf { dir.join(format!("{prefix}-{}.db8", sanitized_recovery_title(title))) } +/// Flow-session variant: same directory/naming law, `.fl0` container. +pub fn collaboration_flow_recovery_path(session: SessionId, title: &str) -> PathBuf { + collaboration_recovery_path(session, title).with_extension("fl0") +} + fn sanitized_recovery_title(title: &str) -> String { let mut out = String::new(); for ch in title.chars() { diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index 1ba3a411..6894cd3b 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -6,9 +6,10 @@ use std::{ use anyhow::{Context as _, Result, anyhow, bail}; use flowstate_collab::{ - SessionAdmission, SessionId, + DocumentKind, SessionAdmission, SessionId, SyncIoHandle, crdt_runtime::{CrdtRuntime, RuntimeEvent}, doc_io::DocIoHandle, + flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}, ids::PeerId, local_write::{LocalDocHandle, LocalWriteConfig}, net::{ @@ -26,6 +27,7 @@ use loro::{LoroDoc, Subscription as LoroSubscription}; use uuid::Uuid; use crate::app_settings::{load_document_theme, load_local_user_identity, load_local_user_profile}; +use crate::flow::{FlowEditor, FlowEditorEvent}; use crate::rich_text_element::{AssetId, AssetRecord, CollaborationRole, DocumentProjection, EditorEvent, RichTextEditor}; use super::presence_view; @@ -94,7 +96,43 @@ pub(super) enum JoinNeighborSignal { pub struct JoinedDocument { pub session: SessionId, pub title: String, - pub document: DocumentProjection, + pub payload: JoinedDocumentPayload, +} + +/// The join snapshot's materialization, per document kind. +pub enum JoinedDocumentPayload { + RichText(DocumentProjection), + Flow(flowstate_flow::FlowBoardProjection), +} + +/// The session's document-facing editor arm (spec Part C): ONE session type, +/// two-arm document half. +#[derive(Clone)] +pub enum CollabEditor { + RichText(Entity), + Flow(Entity), +} + +impl CollabEditor { + pub fn as_rich_text(&self) -> Option<&Entity> { + match self { + Self::RichText(editor) => Some(editor), + Self::Flow(_) => None, + } + } + + pub fn as_flow(&self) -> Option<&Entity> { + match self { + Self::Flow(editor) => Some(editor), + Self::RichText(_) => None, + } + } +} + +/// The parked join-handoff write authority, per document kind. +pub enum JoinedAuthority { + RichText(Arc), + Flow(Arc), } #[derive(Clone, Debug)] @@ -111,13 +149,14 @@ pub struct CollabSession { // Loro-first (spec §3, invariant 5): the session is TRANSPORT-ONLY. It holds // the document I/O service handle to import remote updates and drain the // publish queue — never a write path into the document. - runtime: Option, + runtime: Option, // JOIN handoff slot (spec §3 join gate): the write authority constructed by // `finish_join_snapshot`, held ONLY until the workspace takes it via // `take_joined_document_services`. The session never calls into it. - join_authority: Option>, + join_authority: Option, runtime_vv: Vec, - editor: Option>, + kind: DocumentKind, + editor: Option, panel_id: Option, // UI-only asset cache records fetched after Loro metadata arrives. pending_asset_records: Vec<(AssetId, AssetRecord)>, @@ -179,6 +218,48 @@ impl CollabSession { title: String, io: DocIoHandle, net_tx: CommandSender, + ) -> Self { + Self::from_local_parts( + session, + panel_id, + CollabEditor::RichText(editor), + title, + SyncIoHandle::RichText(io), + DocumentKind::RichText, + net_tx, + ) + } + + /// Start a session on an open FLOW tab: the identical wiring shape (the + /// editor already holds its write authority; the session only takes the + /// transport-side flow I/O handle). + pub fn from_local_flow_runtime( + session: SessionId, + panel_id: Uuid, + editor: Entity, + title: String, + io: FlowIoHandle, + net_tx: CommandSender, + ) -> Self { + Self::from_local_parts( + session, + panel_id, + CollabEditor::Flow(editor), + title, + SyncIoHandle::Flow(io), + DocumentKind::Flow, + net_tx, + ) + } + + fn from_local_parts( + session: SessionId, + panel_id: Uuid, + editor: CollabEditor, + title: String, + io: SyncIoHandle, + kind: DocumentKind, + net_tx: CommandSender, ) -> Self { // Direct serving is network-bound and should backpressure rather than grow without limit. let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); @@ -197,6 +278,7 @@ impl CollabSession { runtime: Some(io), join_authority: None, runtime_vv: Vec::new(), + kind, editor: Some(editor), panel_id: Some(panel_id), pending_asset_records: Vec::new(), @@ -239,7 +321,14 @@ impl CollabSession { } } - pub fn joining(session: SessionId, title: String, net_tx: CommandSender, bootstrap_addrs: Vec, admission: SessionAdmission) -> Self { + pub fn joining( + session: SessionId, + title: String, + net_tx: CommandSender, + bootstrap_addrs: Vec, + admission: SessionAdmission, + kind: DocumentKind, + ) -> Self { let collaboration_role = CollaborationRole::Editor; let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); let now = Instant::now(); @@ -251,6 +340,7 @@ impl CollabSession { runtime: None, join_authority: None, runtime_vv: Vec::new(), + kind, editor: None, panel_id: None, pending_asset_records: Vec::new(), @@ -306,10 +396,18 @@ impl CollabSession { } /// The session's document kind (rides the v4 invite ticket under its HMAC). - /// Step 9 (session enum split) derives this from the attached editor arm; - /// every current session is a rich-text document. - pub fn document_kind(&self) -> flowstate_collab::ticket::DocumentKind { - flowstate_collab::ticket::DocumentKind::RichText + pub fn document_kind(&self) -> DocumentKind { + self.kind + } + + /// The rich-text arm of the attached editor (presence/comments/assets are + /// rich-text-only surfaces; flow arms no-op through this accessor). + pub(super) fn rich_text_editor(&self) -> Option> { + self.editor.as_ref().and_then(CollabEditor::as_rich_text).cloned() + } + + pub(super) fn flow_editor(&self) -> Option> { + self.editor.as_ref().and_then(CollabEditor::as_flow).cloned() } pub(super) fn set_admission(&mut self, admission: SessionAdmission) { @@ -342,7 +440,7 @@ impl CollabSession { // thread (spec I-9a: raw ungated doc reads are outlawed — they can // force-commit mid-intent state). Absent a runtime yet, it falls back to // the session-served request channel. - DirectSessionHandler::new_rich_text(self.direct_tx.clone(), self.runtime.clone()) + DirectSessionHandler::new(self.direct_tx.clone(), self.runtime.clone()) } pub(super) fn pull_candidates(&self, preferred: Option) -> Vec { @@ -522,7 +620,7 @@ impl CollabSession { Ok(reply_rx) } - pub fn attach_joined_editor(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> { + pub fn attach_joined_editor(&mut self, panel_id: Uuid, editor: CollabEditor, cx: &mut Context) -> Result<()> { if self.runtime.is_none() { tracing::warn!(session = %self.session, %panel_id, "cannot attach joined editor before snapshot load finishes"); bail!("collaboration snapshot has not finished loading"); @@ -584,7 +682,7 @@ impl CollabSession { /// (the session must never hold a write path once attached) and the I/O /// handle is shared. Returns `None` before the snapshot import completes or /// after the services were already taken. - pub fn take_joined_document_services(&mut self) -> Option<(Arc, DocIoHandle)> { + pub fn take_joined_document_services(&mut self) -> Option<(JoinedAuthority, SyncIoHandle)> { let io = self.runtime.clone()?; let authority = self.join_authority.take()?; Some((authority, io)) @@ -704,7 +802,7 @@ impl CollabSession { } else { tracing::debug!(session = %self.session, peer = %peer, "local collaboration peer presence already established"); } - if let (Some(editor), Some(presence)) = (self.editor.clone(), self.presence.as_ref()) { + if let (Some(editor), Some(presence)) = (self.rich_text_editor(), self.presence.as_ref()) { editor.update(cx, |editor, cx| editor.set_own_collaboration_caret_color(Some(presence.self_color()), cx)); } self.refresh_own_presence(cx); @@ -721,7 +819,7 @@ impl CollabSession { } fn finish_join_snapshot(&mut self, snapshot: &[u8], cx: &mut Context) -> Result { - tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), "building collaboration document from join snapshot"); + tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), kind = ?self.kind, "building collaboration document from join snapshot"); if matches!(self.phase, SessionPhase::Detached(_)) { bail!("collaboration join is no longer active"); } @@ -734,6 +832,10 @@ impl CollabSession { self.phase = SessionPhase::Joining(JoinStage::Building); cx.notify(); + if matches!(self.kind, DocumentKind::Flow) { + return self.finish_join_flow_snapshot(snapshot); + } + let doc = LoroDoc::new(); flowstate_document::loro_schema::configure_text_styles(&doc); doc @@ -749,7 +851,8 @@ impl CollabSession { // cannot receive it before this point, i.e. before the initial snapshot // import completed). let (authority, gate) = LocalDocHandle::new(runtime, LocalWriteConfig::default()); - let io = DocIoHandle::spawn(gate).context("starting joined collaboration document I/O service")?; + let doc_io = DocIoHandle::spawn(gate).context("starting joined collaboration document I/O service")?; + let io = doc_io.clone(); // §15/§31: bind this joiner's durable author identity to the joined // document so their revisions record an author and `users_by_id` is // populated. The user-registration op converges to peers via @@ -757,7 +860,7 @@ impl CollabSession { // the join. `create_document_panel` receives this document through the // `Attachment` source, which deliberately skips re-binding to avoid a // redundant second call. - let identity_io = io.clone(); + let identity_io = doc_io.clone(); cx.spawn(async move |session, cx| { let (user_id, display_name) = cx .background_executor() @@ -785,8 +888,8 @@ impl CollabSession { "built collaboration document from join snapshot", ); - self.runtime = Some(io); - self.join_authority = Some(Arc::new(authority)); + self.runtime = Some(SyncIoHandle::RichText(io)); + self.join_authority = Some(JoinedAuthority::RichText(Arc::new(authority))); fidelity::event(FidelityClass::Frontier, "runtime-vv-reset", || { format!("session={} source=join-snapshot prior_bytes={}", self.session, self.runtime_vv.len()) }); @@ -794,7 +897,33 @@ impl CollabSession { Ok(JoinedDocument { session: self.session, title: format!("{} (shared)", self.title), - document, + payload: JoinedDocumentPayload::RichText(document), + }) + } + + /// The FLOW join arm (spec Part C): `FlowRuntime::from_snapshot` (schema + /// validated — the wrong-kind defense) → `FlowDocHandle::new` → + /// `FlowIoHandle::spawn` → park the authority for the workspace handoff. + fn finish_join_flow_snapshot(&mut self, snapshot: &[u8]) -> Result { + let runtime = FlowRuntime::from_snapshot(snapshot).context("building joined flow runtime from snapshot")?; + let board = runtime.board_ref().clone(); + let (authority, gate) = FlowDocHandle::new(runtime); + let io = FlowIoHandle::spawn(gate).context("starting joined flow I/O service")?; + tracing::info!( + session = %self.session, + sheets = board.sheets.len(), + "built collaboration flow document from join snapshot", + ); + self.runtime = Some(SyncIoHandle::Flow(io)); + self.join_authority = Some(JoinedAuthority::Flow(authority)); + fidelity::event(FidelityClass::Frontier, "runtime-vv-reset", || { + format!("session={} source=join-snapshot prior_bytes={}", self.session, self.runtime_vv.len()) + }); + self.runtime_vv.clear(); + Ok(JoinedDocument { + session: self.session, + title: format!("{} (shared)", self.title), + payload: JoinedDocumentPayload::Flow(board), }) } @@ -826,13 +955,22 @@ impl CollabSession { // document's write authority, gate, and I/O service are untouched — the // editor keeps editing through the identical path; nothing to reset on it // beyond collaboration presentation state. - if let Some(editor) = self.editor.clone() { - editor.update(cx, |editor, cx| { - editor.set_recovery_path(None, cx); - editor.set_collaboration_role(None, cx); - editor.set_own_collaboration_caret_color(None, cx); - editor.set_external_carets(Vec::new(), cx); - }); + match self.editor.clone() { + Some(CollabEditor::RichText(editor)) => { + editor.update(cx, |editor, cx| { + editor.set_recovery_path(None, cx); + editor.set_collaboration_role(None, cx); + editor.set_own_collaboration_caret_color(None, cx); + editor.set_external_carets(Vec::new(), cx); + }); + }, + Some(CollabEditor::Flow(editor)) => { + editor.update(cx, |editor, cx| { + editor.set_recovery_path(None, cx); + editor.set_external_presences(Vec::new(), cx); + }); + }, + None => {}, } self.editor_subscriptions.clear(); @@ -885,30 +1023,82 @@ impl CollabSession { return; }; let session_id = self.session; - cx.spawn(async move |session, cx| { - match io.pump_publish().await { - Ok(events) => { - if events.is_empty() { - return; + match io { + SyncIoHandle::RichText(io) => { + cx.spawn(async move |session, cx| { + match io.pump_publish().await { + Ok(events) => { + if events.is_empty() { + return; + } + let published = events.len(); + let _ = session.update(cx, |session, cx| { + fidelity::event(FidelityClass::Reconcile, "publish-pump", || { + format!("session={session_id} events={published}") + }); + if let Err(error) = session.apply_runtime_events(events, false, cx) { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "publishing committed local collaboration updates failed"); + return; + } + session.refresh_external_carets(cx); + }); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping collaboration local update publish queue failed"); + }, } - let published = events.len(); - let _ = session.update(cx, |session, cx| { - fidelity::event(FidelityClass::Reconcile, "publish-pump", || { - format!("session={session_id} events={published}") - }); - if let Err(error) = session.apply_runtime_events(events, false, cx) { - tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "publishing committed local collaboration updates failed"); - return; - } - session.refresh_external_carets(cx); - }); + }) + .detach(); + }, + SyncIoHandle::Flow(io) => { + cx.spawn(async move |session, cx| { + match io.pump_publish().await { + Ok(events) => { + if events.is_empty() { + return; + } + let published = events.len(); + let _ = session.update(cx, |session, cx| { + fidelity::event(FidelityClass::Reconcile, "publish-pump", || { + format!("session={session_id} kind=flow events={published}") + }); + session.apply_flow_events(events, cx); + }); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping flow publish queue failed"); + }, + } + }) + .detach(); + }, + } + } + + /// The flow arm of the publish/apply pump: `FlowPublishEvent`s ride the + /// SAME vv-tracking + gossip publication the rich-text events use. + pub(super) fn apply_flow_events(&mut self, events: Vec, cx: &mut Context) { + for event in events { + match event { + FlowPublishEvent::LocalUpdate { bytes, version_vector, .. } => { + self.update_runtime_vv(version_vector, "local-update", true); + self.publish_update_bytes(bytes); }, - Err(error) => { - tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping collaboration local update publish queue failed"); + FlowPublishEvent::RemoteUpdateApplied { version_vector, .. } => { + self.update_runtime_vv(version_vector, "remote-update-applied", true); }, } - }) - .detach(); + } + self.last_document_activity = Instant::now(); + // The projection changes ride the ordered board/cell streams; pump the + // flow editor to drain them. + if let Some(editor) = self.flow_editor() { + editor.update(cx, |editor, cx| { + editor.sync_board_from_handle(cx); + editor.sync_cell_editors_from_authority(cx); + }); + } + cx.notify(); } pub(super) fn apply_runtime_events(&mut self, events: Vec, apply_projection: bool, cx: &mut Context) -> Result<()> { @@ -989,7 +1179,7 @@ impl CollabSession { self.apply_runtime_projection(*document, cx)?; }, RuntimeEvent::SelectionRestored { selection } if apply_projection => { - if let Some(editor) = self.editor.clone() { + if let Some(editor) = self.rich_text_editor() { editor.update(cx, |editor, cx| editor.restore_runtime_selection(selection, cx)); } }, @@ -1011,7 +1201,7 @@ impl CollabSession { } fn apply_runtime_patches(&mut self, batch: flowstate_document::ProjectionPatchBatch, cx: &mut Context) -> Result<()> { - let Some(editor) = self.editor.clone() else { + let Some(editor) = self.rich_text_editor() else { return Ok(()); }; fidelity::event(FidelityClass::Projection, "apply-projection-patches", || { @@ -1035,7 +1225,7 @@ impl CollabSession { } fn apply_runtime_projection(&mut self, mut document: DocumentProjection, cx: &mut Context) -> Result<()> { - let Some(editor) = self.editor.clone() else { + let Some(editor) = self.rich_text_editor() else { return Ok(()); }; fidelity::event(FidelityClass::Projection, "apply-projection", || { @@ -1167,6 +1357,13 @@ impl CollabSession { tracing::warn!(session = %self.session, "cannot attach collaboration editor hooks because editor is missing"); return; }; + let editor = match editor { + CollabEditor::RichText(editor) => editor, + CollabEditor::Flow(editor) => { + self.attach_flow_editor_hooks(&editor, cx); + return; + }, + }; tracing::debug!(session = %self.session, "attaching collaboration editor hooks"); editor.update(cx, |editor, cx| { @@ -1225,6 +1422,34 @@ impl CollabSession { tracing::debug!(session = %self.session, subscriptions = self.editor_subscriptions.len(), "collaboration editor hooks attached"); } + /// FLOW editor hooks (spec Part C): observe → debounced publish pump; + /// pathless joined tabs get a debounced `.fl0` recovery file. + fn attach_flow_editor_hooks(&mut self, editor: &Entity, cx: &mut Context) { + tracing::debug!(session = %self.session, "attaching collaboration flow editor hooks"); + editor.update(cx, |editor, cx| { + if editor.document_path().is_none() { + editor.set_recovery_path(Some(presence_view::collaboration_flow_recovery_path(self.session, &self.title)), cx); + } + }); + self + .editor_subscriptions + .push(cx.observe(editor, |session, _, cx| { + session.schedule_publish_pump(cx); + })); + self + .editor_subscriptions + .push(cx.subscribe(editor, |session, _, event: &FlowEditorEvent, cx| { + match event { + FlowEditorEvent::Changed => session.schedule_publish_pump(cx), + FlowEditorEvent::ActiveCellChanged(_) | FlowEditorEvent::ActiveSheetChanged(_) => { + // Step 11 (presence): board focus rides the presence channel. + session.schedule_own_presence_refresh(cx); + }, + } + })); + tracing::debug!(session = %self.session, subscriptions = self.editor_subscriptions.len(), "collaboration flow editor hooks attached"); + } + fn schedule_publish_pump(&mut self, cx: &mut Context) { if self.publish_pump_pending { return; diff --git a/crates/flowstate/src/collab/session_io.rs b/crates/flowstate/src/collab/session_io.rs index 6fa508dc..a5337098 100644 --- a/crates/flowstate/src/collab/session_io.rs +++ b/crates/flowstate/src/collab/session_io.rs @@ -64,10 +64,33 @@ impl CollabSession { } tracing::debug!(session = %self.session, bytes = bytes.len(), "importing remote collaboration update"); - let io = self + let io = match self .runtime .clone() - .context("collaboration session has no document I/O service")?; + .context("collaboration session has no document I/O service")? + { + flowstate_collab::SyncIoHandle::RichText(io) => io, + flowstate_collab::SyncIoHandle::Flow(io) => { + // FLOW arm: one import request; the derivation rides the runtime's + // streams and the pump drains vv/publish events afterwards. + let session_id = self.session; + cx.spawn(async move |session, cx| { + match io.import_remote_update(bytes).await { + Ok(()) => { + let _ = session.update(cx, |session, cx| { + session.pump_publish(cx); + session.last_document_activity = std::time::Instant::now(); + }); + }, + Err(error) => { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "dropped unimportable remote flow update"); + }, + } + }) + .detach(); + return Ok(()); + }, + }; // §perf: bytes is already owned; hand it straight to the I/O service with no extra copy. let bytes_len = bytes.len(); let session_id = self.session; @@ -254,8 +277,8 @@ impl CollabSession { } pub(super) fn flush_pending_asset_records(&mut self, cx: &mut Context) -> bool { - let Some(editor) = self.editor.clone() else { - tracing::trace!(session = %self.session, pending_asset_records = self.pending_asset_records.len(), "cannot flush collaboration asset records because editor is missing"); + let Some(editor) = self.rich_text_editor() else { + tracing::trace!(session = %self.session, pending_asset_records = self.pending_asset_records.len(), "cannot flush collaboration asset records because no rich-text editor is attached"); return false; }; let deferred = editor.read(cx).projection_apply_deferred(); @@ -337,9 +360,8 @@ impl CollabSession { fn asset_bytes(&self, asset: u128, cx: &mut Context) -> Result { let editor = self - .editor - .as_ref() - .context("collaboration session has no editor")?; + .rich_text_editor() + .context("collaboration session has no rich-text editor (flow sessions carry no assets)")?; let bytes = editor .read(cx) .document() @@ -365,7 +387,7 @@ impl CollabSession { // replica's own saves/packages include them. The session still never // writes document CONTENT (invariant 5) — asset bytes are content- // addressed sideband records. - if let Some(io) = self.runtime.clone() { + if let Some(io) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) { let records: Vec = asset_records .iter() .map(|(_, record)| record.clone()) diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs index 9b814eed..8b531ca9 100644 --- a/crates/flowstate/src/collab/session_presence.rs +++ b/crates/flowstate/src/collab/session_presence.rs @@ -75,10 +75,9 @@ impl CollabSession { return; } let selection = self - .editor - .as_ref() + .rich_text_editor() .map(|editor| editor.read(cx).selection().clone()); - let runtime = self.runtime.clone(); + let runtime = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()); let session_id = self.session; self.presence_refresh_generation = self.presence_refresh_generation.wrapping_add(1); let generation = self.presence_refresh_generation; @@ -159,8 +158,10 @@ impl CollabSession { if self.comment_annotation_refresh_pending { return; } - let Some(runtime) = self.runtime.clone() else { return }; - let Some(editor) = self.editor.clone() else { return }; + let Some(runtime) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) else { + return; + }; + let Some(editor) = self.rich_text_editor() else { return }; self.comment_annotation_refresh_pending = true; self.comment_annotation_refresh_generation = self.comment_annotation_refresh_generation.wrapping_add(1); let generation = self.comment_annotation_refresh_generation; @@ -176,9 +177,8 @@ impl CollabSession { match result { Ok(comments) if session - .editor - .as_ref() - .is_some_and(|current| current == &editor) => + .rich_text_editor() + .is_some_and(|current| current == editor) => { let selections = comments .into_iter() @@ -204,12 +204,12 @@ impl CollabSession { tracing::trace!(session = %self.session, "skipping external caret refresh because presence is missing"); return; }; - let Some(runtime) = self.runtime.clone() else { - tracing::trace!(session = %self.session, "skipping external caret refresh because Loro doc is missing"); + let Some(runtime) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) else { + tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text runtime is attached"); return; }; - let Some(editor) = self.editor.clone() else { - tracing::trace!(session = %self.session, "skipping external caret refresh because editor is missing"); + let Some(editor) = self.rich_text_editor() else { + tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text editor is attached"); return; }; // §perf: self_key is only used for equality; borrow it instead of allocating a String. @@ -248,9 +248,8 @@ impl CollabSession { } tracing::trace!(session = %session_id, carets = resolved.carets.len(), "refreshing collaboration external carets"); if session - .editor - .as_ref() - .is_some_and(|current| current == &editor) + .rich_text_editor() + .is_some_and(|current| current == editor) { if fidelity::enabled() { // A resolved external caret must land inside the editor's current diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index eb7a84bd..5326112f 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -46,6 +46,18 @@ pub enum FlowEditorEvent { ActiveSheetChanged(Option), } +/// One remote peer's board focus, resolved for rendering: which sheet they +/// are on, which cell they focus (outline + name chip), and whether they are +/// editing inside it. +#[derive(Clone, Debug, PartialEq)] +pub struct FlowExternalPresence { + pub name: String, + pub color_rgb: u32, + pub sheet: Option, + pub cell: Option, + pub editing: bool, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AnnotationTool { #[default] @@ -70,6 +82,11 @@ pub struct FlowEditor { /// demand, dropped on any board change / remote sync). cell_documents: std::collections::HashMap, path: Option, + /// Debounced `.fl0` recovery file for pathless joined collaboration tabs. + recovery_path: Option, + recovery_write_pending: bool, + /// Remote peers' board focus (presence rendering, spec Part C). + external_presences: Vec, dirty: bool, active_sheet: Option, active_cell: Option, @@ -117,6 +134,9 @@ impl FlowEditor { board, cell_documents: std::collections::HashMap::new(), path, + recovery_path: None, + recovery_write_pending: false, + external_presences: Vec::new(), dirty: false, active_sheet, active_cell: None, @@ -673,6 +693,7 @@ impl FlowEditor { fn changed(&mut self, active_cell: Option, cx: &mut Context) { self.active_cell = active_cell; self.dirty = true; + self.schedule_recovery_write(cx); cx.emit(FlowEditorEvent::Changed); cx.emit(FlowEditorEvent::ActiveCellChanged(active_cell)); cx.notify(); @@ -714,7 +735,66 @@ impl FlowEditor { }) } - pub fn discard_recovery_file(&mut self) {} + /// Pathless joined tabs write a debounced `.fl0` recovery file so a crash + /// mid-session cannot lose the shared board (lifecycle parity, spec Part C). + pub fn set_recovery_path(&mut self, path: Option, cx: &mut Context) { + self.recovery_path = path; + if self.recovery_path.is_some() { + self.schedule_recovery_write(cx); + } + } + + fn schedule_recovery_write(&mut self, cx: &mut Context) { + if self.recovery_write_pending { + return; + } + let Some(path) = self.recovery_path.clone() else { + return; + }; + self.recovery_write_pending = true; + let handle = self.handle.clone(); + cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(750)) + .await; + let write = cx + .background_executor() + .spawn(async move { + let snapshot = handle.snapshot().map_err(std::io::Error::other)?; + flowstate_flow::write_fl0(&path, &snapshot).map_err(std::io::Error::other) + }) + .await; + if let Err(error) = write { + tracing::warn!(%error, "writing flow collaboration recovery file failed"); + } + let _ = editor.update(cx, |editor, _| { + editor.recovery_write_pending = false; + }); + }) + .detach(); + } + + pub fn discard_recovery_file(&mut self) { + if let Some(path) = self.recovery_path.take() + && let Err(error) = std::fs::remove_file(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(%error, path = %path.display(), "removing flow collaboration recovery file failed"); + } + } + + /// Remote peers' board focus (peer-color cell outlines + name chips; peers + /// on other sheets show as colored dots on the sheet switcher). + pub fn set_external_presences(&mut self, presences: Vec, cx: &mut Context) { + if self.external_presences != presences { + self.external_presences = presences; + cx.notify(); + } + } + + pub fn external_presences(&self) -> &[FlowExternalPresence] { + &self.external_presences + } pub fn resolve_pending(&mut self, _cx: &mut Context) {} diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index 64f3348f..57d8b9ab 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -3,7 +3,7 @@ pub mod editor; pub mod panel; pub mod ribbon; -pub use editor::{AnnotationTool, FlowEditor}; +pub use editor::{AnnotationTool, FlowEditor, FlowEditorEvent}; pub use panel::FlowPanel; pub use ribbon::FlowRibbon; diff --git a/crates/flowstate/src/workspace/workspace/collab.rs b/crates/flowstate/src/workspace/workspace/collab.rs index 8c1d7de8..1da63a1c 100644 --- a/crates/flowstate/src/workspace/workspace/collab.rs +++ b/crates/flowstate/src/workspace/workspace/collab.rs @@ -1,13 +1,23 @@ #[hotpath::measure_all] impl Workspace { pub(crate) fn collaboration_discovery_context(&self, panel_id: Uuid, cx: &App) -> Option<(u128, PathBuf)> { - let panel = self + if let Some(panel) = self .document_panels .iter() + .find(|panel| panel.read(cx).id() == panel_id) + { + let editor = panel.read(cx).editor(); + let editor = editor.read(cx); + return Some((editor.document().ids.document_id, editor.document_path()?.clone())); + } + // FLOW arm: discovery fingerprints derive from `flow.meta/document_id`. + let panel = self + .flow_panels + .iter() .find(|panel| panel.read(cx).id() == panel_id)?; let editor = panel.read(cx).editor(); let editor = editor.read(cx); - Some((editor.document().ids.document_id, editor.document_path()?.clone())) + Some((editor.handle().document_id().ok()?.as_u128(), editor.document_path()?.clone())) } pub fn open_comment_dialog(&mut self, window: &mut Window, cx: &mut Context) { @@ -68,7 +78,9 @@ impl Workspace { self.collaboration_dialog = None; } let workspace = cx.entity().downgrade(); - let panel_id = self.active_editor.as_ref().and(self.active_document_id); + let panel_id = (self.active_editor.is_some() || self.active_flow.is_some()) + .then_some(self.active_document_id) + .flatten(); let dialog = cx.new(|cx| crate::collab::share_dialog::CollabShareDialog::new(workspace, panel_id, mode, window, cx)); let dialog_for_render = dialog.clone(); let workspace_for_close = cx.entity().downgrade(); @@ -93,6 +105,9 @@ impl Workspace { } pub fn start_collaboration_on_document(&mut self, panel_id: Uuid, cx: &mut Context) -> Option { + if self.flow_panels.iter().any(|panel| panel.read(cx).id() == panel_id) { + return self.start_collaboration_on_flow_panel(panel_id, cx); + } let panel = self .document_panels .iter() @@ -121,6 +136,30 @@ impl Workspace { } } + /// FLOW arm of session start: what makes "start collaboration on an open + /// flow tab" free — the editor already commits through its gated authority; + /// the session only takes the flow I/O handle for transport. + fn start_collaboration_on_flow_panel(&mut self, panel_id: Uuid, cx: &mut Context) -> Option { + let panel = self + .flow_panels + .iter() + .find(|panel| panel.read(cx).id() == panel_id)?; + let editor = panel.read(cx).editor(); + let title = panel.read(cx).title_text().to_string(); + let runtime = self.flow_document_runtimes.get(&panel_id)?.clone(); + tracing::info!(%panel_id, title = %title, "workspace starting collaboration on flow"); + match crate::collab::start_flow_session_for_panel(panel_id, editor, title, runtime, cx) { + Ok(session) => { + tracing::info!(%panel_id, %session, "workspace started collaboration on flow"); + Some(session) + }, + Err(error) => { + tracing::error!(%panel_id, error = %format_args!("{error:#}"), "starting flow collaboration session failed"); + None + }, + } + } + pub fn request_active_collaboration_ticket( &mut self, cx: &mut Context, @@ -310,24 +349,43 @@ impl Workspace { )); return; }; - let attachment = DocumentRuntimeAttachment { authority, io }; - let panel = match workspace.add_joined_collaboration_panel(joined.document, joined.title, attachment, window, cx) { - Ok(panel) => panel, - Err(error) => { - tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); - let detail = format!("Joined document runtime could not be started: {error:#}"); + use crate::collab::{CollabEditor, JoinedAuthority, JoinedDocumentPayload}; + let (panel_id, editor) = match (joined.payload, authority, io) { + (JoinedDocumentPayload::RichText(document), JoinedAuthority::RichText(authority), flowstate_collab::SyncIoHandle::RichText(io)) => { + let attachment = DocumentRuntimeAttachment { authority, io }; + let panel = match workspace.add_joined_collaboration_panel(document, joined.title, attachment, window, cx) { + Ok(panel) => panel, + Err(error) => { + tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); + let detail = format!("Joined document runtime could not be started: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + return; + }, + }; + (panel.read(cx).id(), CollabEditor::RichText(panel.read(cx).editor())) + }, + (JoinedDocumentPayload::Flow(_board), JoinedAuthority::Flow(authority), flowstate_collab::SyncIoHandle::Flow(io)) => { + let panel = workspace.add_joined_collaboration_flow_panel(authority, io, joined.title, window, cx); + (panel.read(cx).id(), CollabEditor::Flow(panel.read(cx).editor())) + }, + _ => { + tracing::error!(session = %joined.session, "joined collaboration services have mismatched document kinds"); std::mem::drop(window.prompt( PromptLevel::Critical, "Join failed", - Some(&detail), + Some("The joined collaboration document kind is inconsistent."), &[PromptButton::ok("Ok")], cx, )); return; }, }; - let panel_id = panel.read(cx).id(); - let editor = panel.read(cx).editor(); if let Err(error) = crate::collab::attach_joined_session(joined.session, panel_id, editor, cx) { tracing::error!(session = %joined.session, %panel_id, error = %format_args!("{error:#}"), "collaboration joined document attachment failed"); let detail = format!("Joined document opened, but collaboration attachment failed: {error:#}"); @@ -390,6 +448,24 @@ impl Workspace { Ok(panel) } + /// FLOW join sibling of [`Self::add_joined_collaboration_panel`]: the + /// session built authority + I/O from the snapshot; the panel wires them + /// exactly like a solo flow tab (invariant 5) — pathless, so autosave skips + /// it and the session's recovery hooks cover crashes. + fn add_joined_collaboration_flow_panel( + &mut self, + authority: std::sync::Arc, + io: flowstate_collab::flow::FlowIoHandle, + title: String, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let panel = self.create_flow_panel_from_attachment(authority, io, Some(title), window, cx); + self.persist_temporary_workspace_session(cx); + cx.notify(); + panel + } + pub fn leave_collaboration_on_active_document(&mut self, cx: &mut Context) -> bool { self .active_document_id diff --git a/crates/flowstate/src/workspace/workspace/collab_prompts.rs b/crates/flowstate/src/workspace/workspace/collab_prompts.rs index b1dd6b2f..32086b27 100644 --- a/crates/flowstate/src/workspace/workspace/collab_prompts.rs +++ b/crates/flowstate/src/workspace/workspace/collab_prompts.rs @@ -140,24 +140,30 @@ impl Workspace { } fn collaboration_close_panels(&self, cx: &App) -> Vec { - self - .document_panels - .iter() - .filter_map(|panel| { - let panel_state = panel.read(cx); - let id = panel_state.id(); - let phase = crate::collab::phase_for_panel(id, cx); - collaboration_phase_blocks_close(phase.as_ref()).then(|| { - let panel_kind = PanelKind::Document { - panel: panel.clone(), - editor: panel_state.editor(), - }; - CollaborationClosePanel { - id, - save_prompt: collaboration_save_prompt_for_panel(&panel_kind, cx), - panel: panel_kind, - } - }) + let documents = self.document_panels.iter().filter_map(|panel| { + let panel_state = panel.read(cx); + let id = panel_state.id(); + let phase = crate::collab::phase_for_panel(id, cx); + collaboration_phase_blocks_close(phase.as_ref()).then(|| PanelKind::Document { + panel: panel.clone(), + editor: panel_state.editor(), + }) + }); + let flows = self.flow_panels.iter().filter_map(|panel| { + let panel_state = panel.read(cx); + let id = panel_state.id(); + let phase = crate::collab::phase_for_panel(id, cx); + collaboration_phase_blocks_close(phase.as_ref()).then(|| PanelKind::Flow { + panel: panel.clone(), + editor: panel_state.editor(), + }) + }); + documents + .chain(flows) + .map(|panel_kind| CollaborationClosePanel { + id: panel_kind.panel_id(cx), + save_prompt: collaboration_save_prompt_for_panel(&panel_kind, cx), + panel: panel_kind, }) .collect() } diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 9e4a8533..2f98a7d4 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -1022,12 +1022,45 @@ impl Workspace { cx: &mut Context, ) -> Entity { let FlowRuntimeSource { handle, gate } = source; + // The flow I/O service shares the editor's gate; collaboration and + // off-thread saves route through it (spec Part C). + let io = flowstate_collab::flow::FlowIoHandle::spawn(gate).ok(); + self.create_flow_panel_inner(handle, io, path, None, window, cx) + } + + /// Joined-session variant: the session already built the write authority and + /// spawned its I/O service from the snapshot, so the panel adopts them + /// instead of spawning a duplicate service on the same gate. Pathless — + /// autosave skips it and the session's recovery hooks cover crashes. + fn create_flow_panel_from_attachment( + &mut self, + handle: std::sync::Arc, + io: flowstate_collab::flow::FlowIoHandle, + title: Option, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + self.create_flow_panel_inner(handle, Some(io), None, title, window, cx) + } + + fn create_flow_panel_inner( + &mut self, + handle: std::sync::Arc, + io: Option, + path: Option, + title: Option, + window: &mut Window, + cx: &mut Context, + ) -> Entity { let editor = cx.new(|cx| FlowEditor::new_with_path(handle, path.clone(), window, cx)); let workspace = cx.entity().downgrade(); - let title = path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string()) + let title = title + .or_else(|| { + path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + }) .or_else(|| Some(self.next_untitled_flow_title(cx))); let panel = cx.new(|cx| FlowPanel::new_with_title(title, path, editor.clone(), workspace, window, cx)); let id = panel.read(cx).id(); @@ -1037,9 +1070,7 @@ impl Workspace { workspace.maybe_autosave_flow(id, editor.clone(), cx); }), )); - // The flow I/O service shares the editor's gate; collaboration and - // off-thread saves route through it (spec Part C). - if let Ok(io) = flowstate_collab::flow::FlowIoHandle::spawn(gate) { + if let Some(io) = io { self.flow_document_runtimes.insert(id, io); } self.active_document_id = Some(id); diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs index 17b48544..58254cff 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs @@ -50,3 +50,83 @@ fn start_and_leave_collaboration_session_round_trip(cx: &mut TestAppContext) { assert!(left, "leaving the session we just started must succeed"); cx.run_until_parked(); } + +/// Build-order step 9 gate: hosting a session on an open FLOW tab registers a +/// live phase + a roster entry for the local user, and leaving keeps the tab +/// fully editable (invariant 5 — the authority never detaches from the tab). +#[gpui::test] +fn start_and_leave_flow_collaboration_session_round_trip(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let panel_id = h.read(cx, |ws| ws.active_document_id).expect("flow panel id"); + + let session = h.update(cx, |ws, _, cx| ws.start_collaboration_on_active_document(cx)); + assert!(session.is_some(), "hosting a session on a fresh flow must succeed"); + cx.run_until_parked(); + + h.update(cx, |_, _, cx| { + let phase = crate::collab::phase_for_panel(panel_id, cx); + assert!( + phase.is_some_and(|phase| !matches!(phase, crate::collab::SessionPhase::Detached(_))), + "hosted flow session must register a live phase" + ); + }); + // The roster fills when the network endpoint comes online (a real OS + // thread, outside the test dispatcher) — wait for it like `wait_until`. + let mut rostered = false; + for _ in 0..500 { + cx.run_until_parked(); + rostered = h.update(cx, |_, _, cx| { + crate::collab::roster_for_panel(panel_id, cx) + .iter() + .any(|entry| entry.is_self) + }); + if rostered { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(rostered, "hosted flow session must roster the local user"); + + let left = h.update(cx, |ws, _, cx| ws.leave_collaboration_on_active_document(cx)); + assert!(left, "leaving the flow session we just started must succeed"); + cx.run_until_parked(); + + // The tab stays editable after leave: a structural intent still commits + // through the same gated authority. + let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + let sheets_before = h.update(cx, |_, _, cx| flow.read(cx).board().sheets.len()); + h.update(cx, |_, _, cx| flow.update(cx, |editor, cx| editor.create_sheet(cx))); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + assert_eq!( + flow.read(cx).board().sheets.len(), + sheets_before + 1, + "flow tab must stay editable after leaving the session" + ); + }); +} + +/// Build-order step 9 gate: the window-close prompt cascade covers live FLOW +/// sessions (`collaboration_close_panels` scans `flow_panels` too). +#[gpui::test] +fn flow_collaboration_close_prompt_cascade(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let session = h.update(cx, |ws, _, cx| ws.start_collaboration_on_active_document(cx)); + assert!(session.is_some(), "hosting a session on a fresh flow must succeed"); + cx.run_until_parked(); + + let intercepted = h.update(cx, |ws, window, cx| ws.request_close_window_with_collaboration(window, cx)); + assert!(intercepted, "closing with a live flow session must be intercepted by the leave prompt"); + cx.run_until_parked(); + assert!(cx.has_pending_prompt(), "the leave-and-quit prompt must be pending"); + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + let left = h.update(cx, |ws, _, cx| ws.leave_collaboration_on_active_document(cx)); + assert!(left, "cancelled close must leave the session intact and leavable"); + cx.run_until_parked(); +} diff --git a/crates/flowstate/src/workspace/workspace/render_top_bar.rs b/crates/flowstate/src/workspace/workspace/render_top_bar.rs index cfbe4778..b54893bd 100644 --- a/crates/flowstate/src/workspace/workspace/render_top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/render_top_bar.rs @@ -27,7 +27,11 @@ impl Workspace { !self.ribbon_collapsed, )) .child(div().flex_1()) - .child(share_top_bar_button(cx, self.active_editor.is_some(), active_collaborating)) + .child(share_top_bar_button( + cx, + self.active_editor.is_some() || self.active_flow.is_some(), + active_collaborating, + )) .child(settings_top_bar_button(cx)), ) } From c0c9d8e96c18e9078071741b1d90da2cfcb5ec72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:33:54 +0000 Subject: [PATCH 22/24] =?UTF-8?q?flowstate:=20flow=20join=20handoff=20gate?= =?UTF-8?q?=20=E2=80=94=20pathless=20tab,=20recovery=20file=20round=20trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless gate for the join path: a snapshot-built flow authority + I/O service hand off into a pathless workspace tab that paints the host's board and stays editable through its own gate; autosave skips it; the debounced .fl0 recovery file is written after edits and removed by discard. The two-instance loopback join (snapshot + incremental updates over the direct protocol) is covered by swarm_loopback::flow_direct_snapshot_and_updates_pull. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- .../workspace/headless_tests/collab_glue.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs index 58254cff..12bdc896 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs @@ -108,6 +108,78 @@ fn start_and_leave_flow_collaboration_session_round_trip(cx: &mut TestAppContext }); } +/// Build-order step 10 gate: the join handoff — a snapshot-built flow +/// authority + I/O service become a PATHLESS tab (autosave skips it) that +/// paints the host's board and stays editable through its own gate, with a +/// debounced `.fl0` recovery file that discard removes. +#[gpui::test] +fn flow_join_handoff_pathless_tab_recovery_written_and_discarded(cx: &mut TestAppContext) { + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowRuntime}; + use flowstate_flow::FlowIntent; + + // "Remote host" side: a flow with one sheet, snapshotted for the joiner. + let format = flowstate_flow::FlowFormat::policy_debate(); + let (host, _host_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("host runtime")); + let sheet_id = uuid::Uuid::new_v4(); + host + .apply(FlowIntent::CreateSheet { + sheet_id, + name: "Shared sheet".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("host sheet"); + let snapshot = host.snapshot().expect("host snapshot"); + let expected_board = host.board_projection().expect("host board"); + + // Joiner session side (the `finish_join_flow_snapshot` shape): runtime from + // snapshot → authority + I/O service, handed to the workspace. + let h = support::open_workspace(cx); + let runtime = FlowRuntime::from_snapshot(&snapshot).expect("joined runtime"); + let (authority, gate) = FlowDocHandle::new(runtime); + let io = FlowIoHandle::spawn(gate).expect("joined flow io"); + h.update(cx, |ws, window, cx| { + ws.add_joined_collaboration_flow_panel(authority, io, "Untitled (shared)".to_string(), window, cx); + }); + cx.run_until_parked(); + + let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("joined flow tab active"); + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert!(editor.document_path().is_none(), "joined tab must be pathless (autosave skips it)"); + assert_eq!(editor.board(), &expected_board, "joined tab paints the host's board"); + }); + + // Recovery: the session sets a recovery path on pathless joined tabs; edits + // schedule a debounced snapshot write. + let dir = std::env::temp_dir().join(format!("flowstate-headless-join-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let recovery = dir.join("recovery.fl0"); + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_recovery_path(Some(recovery.clone()), cx)); + }); + // The joined tab stays editable through its own authority (invariant 5). + h.update(cx, |_, _, cx| flow.update(cx, |editor, cx| editor.create_sheet(cx))); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + assert_eq!(flow.read(cx).board().sheets.len(), 2, "joined tab edits commit through the gate"); + }); + cx.executor().advance_clock(std::time::Duration::from_millis(800)); + let recovery_for_wait = recovery.clone(); + h.wait_until(cx, "flow recovery file write", move |_| recovery_for_wait.exists()); + let recovered = flowstate_flow::read_fl0(&recovery).expect("recovery file is a valid .fl0 v2"); + FlowRuntime::from_snapshot(&recovered).expect("recovery snapshot reloads"); + + // Autosave skipped: the pathless tab stays dirty (nothing saved it). + h.update(cx, |_, _, cx| { + assert!(flow.read(cx).has_unsaved_changes(), "pathless joined tab is never autosaved"); + }); + + // Leave/close discards the recovery file. + h.update(cx, |_, _, cx| flow.update(cx, |editor, _| editor.discard_recovery_file())); + assert!(!recovery.exists(), "discard removes the recovery file"); + std::fs::remove_dir_all(&dir).ok(); +} + /// Build-order step 9 gate: the window-close prompt cascade covers live FLOW /// sessions (`collaboration_close_panels` scans `flow_panels` too). #[gpui::test] From a994c247feaab3ef1a19d913d6ecc4728f2508d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:51:56 +0000 Subject: [PATCH 23/24] =?UTF-8?q?flowstate:=20flow=20presence=20=E2=80=94?= =?UTF-8?q?=20kind-split=20wire=20focus,=20board=20rendering,=20exact=20ce?= =?UTF-8?q?ll=20carets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceState.selection becomes focus: PresenceFocus{Text,Flow} (wire change covered by the protocol v4 bump; caps/impersonation guards apply per-arm, and an oversized flow caret drops just the caret, keeping the board focus). FlowPresenceFocus carries sheet/cell/editing plus the caret as encoded Loro cursors, produced and resolved under the flow gate (FlowDocHandle::presence_selection / resolve_presence_selection), so remote carets land at exact positions and foreign-cell cursors fail safely on the container check. Session glue: the flow arm of refresh_own_presence reads the editor's board focus synchronously; the external refresh maps roster entries to FlowExternalPresence and hands them to the flow editor, which paints a peer-color outline + name chip on the focused cell, colored dots on the peer's sheet row in the outline, and forwards carets into open cell editors via the existing ExternalCaret API (re-resolved after remote imports). Cell caret motion emits CellSelectionChanged so presence republishes on the same debounce as .db8 selection changes. Gate: headless synthetic remote focus asserts the cell outline color, sheet dot, exact forwarded caret offset, and clear-on-leave; presence caps/impersonation suites and the full collab test set stay green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- crates/flowstate-collab/src/flow/handle.rs | 84 ++++++++++++ crates/flowstate-collab/src/presence.rs | 74 +++++++++-- crates/flowstate/src/collab/session.rs | 5 +- .../flowstate/src/collab/session_presence.rs | 49 ++++++- crates/flowstate/src/flow/editor.rs | 122 +++++++++++++++++- .../flowstate/src/flow/editor/cell_editing.rs | 25 +++- crates/flowstate/src/flow/mod.rs | 2 +- .../headless_tests/flows_and_keystrokes.rs | 96 ++++++++++++++ .../src/workspace/workspace/render_outline.rs | 32 ++++- 9 files changed, 458 insertions(+), 31 deletions(-) diff --git a/crates/flowstate-collab/src/flow/handle.rs b/crates/flowstate-collab/src/flow/handle.rs index fb10c58e..1398eaf8 100644 --- a/crates/flowstate-collab/src/flow/handle.rs +++ b/crates/flowstate-collab/src/flow/handle.rs @@ -177,6 +177,79 @@ impl FlowDocHandle { })? } + // ---- Presence (spec Part C, step 11) ---------------------------------------- + + /// Encode a cell-local editor selection as presence cursor bytes (exact Loro + /// cursors — no offset guessing). Advisory: presence rides its own channel, + /// so unlike undo-context capture there is no editor-frontier requirement. + pub fn presence_selection( + &self, + cell: CellId, + selection: &gpui_flowtext::EditorSelection, + ) -> Option { + use crate::presence::{PresenceSelection, SelectionAffinity, SelectionEndpoint, VisualGravity}; + let selection = selection.clone(); + self + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = super::cell_text::CellTextContext::resolve(runtime.doc(), cell).ok()?; + let endpoint = |offset: gpui_flowtext::DocumentOffset, + affinity: SelectionAffinity, + gravity: VisualGravity| + -> Option { + let unicode = ctx.unicode_for_offset(offset)?; + let cursor = crate::crdt_runtime::cursor_for_boundary(&ctx.text, unicode, affinity)?; + Some(SelectionEndpoint { + cursor: cursor.encode(), + affinity, + visual_gravity: gravity, + }) + }; + Some(PresenceSelection { + anchor: endpoint( + selection.anchor, + SelectionAffinity::from(selection.anchor_affinity), + VisualGravity::from(selection.anchor_gravity), + )?, + head: endpoint( + selection.head, + SelectionAffinity::from(selection.head_affinity), + VisualGravity::from(selection.head_gravity), + )?, + direction: selection_direction(selection.anchor, selection.head), + }) + }) + .ok() + .flatten() + } + + /// Resolve a remote peer's presence caret against THIS cell's current text: + /// `(head, anchor)` projection offsets, or `None` when the cursors belong to + /// another container (foreign-cell cursors fail safely) or no longer resolve. + pub fn resolve_presence_selection( + &self, + cell: CellId, + selection: &crate::presence::PresenceSelection, + ) -> Option<(gpui_flowtext::DocumentOffset, gpui_flowtext::DocumentOffset)> { + use loro::{ContainerTrait as _, cursor::Cursor}; + let head_cursor = selection.head.cursor.clone(); + let anchor_cursor = selection.anchor.cursor.clone(); + self + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = super::cell_text::CellTextContext::resolve(runtime.doc(), cell).ok()?; + let resolve = |encoded: &[u8]| { + let cursor = Cursor::decode(encoded).ok()?; + if cursor.container != ctx.text.id() { + return None; + } + let position = runtime.doc().get_cursor_pos(&cursor).ok()?; + ctx.offset_for_unicode(position.current.pos) + }; + Some((resolve(&head_cursor)?, resolve(&anchor_cursor)?)) + }) + .ok() + .flatten() + } + /// Test/tooling support: run a closure against the gate-held runtime (the /// flow analog of holding a test gate guard on the .db8 core). pub fn with_test_runtime(&self, call: impl FnOnce(&mut FlowRuntime) -> T) -> T { @@ -198,3 +271,14 @@ impl FlowDocHandle { Ok(read(&mut guard)) } } + +fn selection_direction( + anchor: gpui_flowtext::DocumentOffset, + head: gpui_flowtext::DocumentOffset, +) -> crate::presence::SelectionDirection { + match anchor.cmp(&head) { + std::cmp::Ordering::Less => crate::presence::SelectionDirection::Forward, + std::cmp::Ordering::Greater => crate::presence::SelectionDirection::Backward, + std::cmp::Ordering::Equal => crate::presence::SelectionDirection::None, + } +} diff --git a/crates/flowstate-collab/src/presence.rs b/crates/flowstate-collab/src/presence.rs index eeea19de..0ae20401 100644 --- a/crates/flowstate-collab/src/presence.rs +++ b/crates/flowstate-collab/src/presence.rs @@ -24,7 +24,48 @@ pub const MAX_PRESENCE_CURSOR_BYTES: usize = 256; pub struct PresenceState { pub name: String, pub color_rgb: u32, - pub selection: Option, + pub focus: Option, +} + +/// What the peer is focused on — kind-specific (spec Part C, presence). The +/// wire change from `Option` is covered by the protocol v4 +/// bump: v3 peers never reach presence exchange with a v4 session. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum PresenceFocus { + /// Rich-text (.db8) selection in the document body. + Text(PresenceSelection), + /// Flow (.fl0) board focus. + Flow(FlowPresenceFocus), +} + +impl PresenceFocus { + #[must_use] + pub fn text(&self) -> Option<&PresenceSelection> { + match self { + Self::Text(selection) => Some(selection), + Self::Flow(_) => None, + } + } + + #[must_use] + pub fn flow(&self) -> Option<&FlowPresenceFocus> { + match self { + Self::Flow(focus) => Some(focus), + Self::Text(_) => None, + } + } +} + +/// A peer's board focus inside a flow session: which sheet they view, which +/// cell they focus, and — because cells are real CRDT text — an exact caret as +/// encoded Loro cursors (resolved under the flow gate on the receiving side). +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FlowPresenceFocus { + pub sheet: Option, + pub cell: Option, + /// Whether the peer has the cell's editor open (vs merely selecting it). + pub editing: bool, + pub caret: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -93,7 +134,7 @@ pub struct RosterEntry { pub key: String, pub name: String, pub color_rgb: u32, - pub selection: Option, + pub focus: Option, } #[derive(Clone, Debug)] @@ -282,22 +323,30 @@ fn roster_entry_from_value(key: String, value: LoroValue) -> Option color_rgb: state.color_rgb & 0x00ff_ffff, key, name: state.name, - selection: state.selection, + focus: state.focus, }) } /// Clamp a presence state to the Part B caps before it is broadcast: strip -/// control characters, truncate the display name, and drop a selection whose +/// control characters, truncate the display name, and drop a focus whose /// cursor encodings exceed the cap (a corrupt/oversized cursor is unusable). fn sanitize_presence_state(state: &PresenceState) -> PresenceState { - let selection = match &state.selection { - Some(selection) if selection_within_caps(selection) => Some(selection.clone()), + let focus = match &state.focus { + Some(focus) if focus_within_caps(focus) => Some(focus.clone()), + Some(PresenceFocus::Flow(focus)) => { + // A flow focus with only an oversized caret is still useful board + // presence — drop just the caret rather than the whole focus. + Some(PresenceFocus::Flow(FlowPresenceFocus { + caret: None, + ..focus.clone() + })) + }, _ => None, }; PresenceState { name: sanitize_display_name(&state.name), color_rgb: state.color_rgb & 0x00ff_ffff, - selection, + focus, } } @@ -323,7 +372,14 @@ fn truncate_to_bytes(text: &str, max_bytes: usize) -> String { fn presence_state_within_caps(state: &PresenceState) -> bool { state.name.len() <= MAX_PRESENCE_NAME_BYTES && !state.name.chars().any(|c| c.is_control()) - && state.selection.as_ref().is_none_or(selection_within_caps) + && state.focus.as_ref().is_none_or(focus_within_caps) +} + +fn focus_within_caps(focus: &PresenceFocus) -> bool { + match focus { + PresenceFocus::Text(selection) => selection_within_caps(selection), + PresenceFocus::Flow(focus) => focus.caret.as_ref().is_none_or(selection_within_caps), + } } fn selection_within_caps(selection: &PresenceSelection) -> bool { @@ -344,7 +400,7 @@ mod tests { PresenceState { name: name.to_string(), color_rgb: 0x3b82f6, - selection: None, + focus: None, } } diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index 6894cd3b..b0012858 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -1441,8 +1441,9 @@ impl CollabSession { .push(cx.subscribe(editor, |session, _, event: &FlowEditorEvent, cx| { match event { FlowEditorEvent::Changed => session.schedule_publish_pump(cx), - FlowEditorEvent::ActiveCellChanged(_) | FlowEditorEvent::ActiveSheetChanged(_) => { - // Step 11 (presence): board focus rides the presence channel. + FlowEditorEvent::ActiveCellChanged(_) | FlowEditorEvent::ActiveSheetChanged(_) | FlowEditorEvent::CellSelectionChanged => { + // Step 11 (presence): board focus + cell caret ride the presence + // channel. session.schedule_own_presence_refresh(cx); }, } diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs index 8b531ca9..d5e0e2b9 100644 --- a/crates/flowstate/src/collab/session_presence.rs +++ b/crates/flowstate/src/collab/session_presence.rs @@ -74,6 +74,25 @@ impl CollabSession { tracing::trace!(session = %self.session, "skipping own collaboration presence refresh because store is missing"); return; } + // FLOW arm: board focus + exact cell caret are read synchronously (the + // flow gate encodes cursors inline, no I/O-thread round trip needed). + if let Some(flow) = self.flow_editor() { + let focus = flow.read(cx).presence_focus(cx); + let state = PresenceState { + name: default_presence_name(), + color_rgb: crate::app_settings::load_local_user_profile().color_rgb, + focus: Some(flowstate_collab::presence::PresenceFocus::Flow(focus)), + }; + if let Some(presence) = &self.presence + && let Err(error) = presence.set_self(&state) + { + tracing::warn!(session = %self.session, error = %format_args!("{error:#}"), "collaboration flow presence encode failed"); + } + if self.refresh_peer_count() { + cx.notify(); + } + return; + } let selection = self .rich_text_editor() .map(|editor| editor.read(cx).selection().clone()); @@ -95,9 +114,9 @@ impl CollabSession { let state = PresenceState { name: default_presence_name(), color_rgb: crate::app_settings::load_local_user_profile().color_rgb, - selection, + focus: selection.map(flowstate_collab::presence::PresenceFocus::Text), }; - tracing::trace!(session = %session_id, has_selection = state.selection.is_some(), "refreshing own collaboration presence"); + tracing::trace!(session = %session_id, has_focus = state.focus.is_some(), "refreshing own collaboration presence"); if let Some(presence) = &session.presence && let Err(error) = presence.set_self(&state) { @@ -204,6 +223,29 @@ impl CollabSession { tracing::trace!(session = %self.session, "skipping external caret refresh because presence is missing"); return; }; + // FLOW arm: remote board focus (sheet/cell/editing + caret) paints via the + // flow editor, which owns cell bounds and open cell editors. + if let Some(flow) = self.flow_editor() { + let self_key = presence.self_key(); + let presences: Vec = presence + .roster() + .into_iter() + .filter(|entry| entry.key != self_key) + .filter_map(|entry| { + let focus = entry.focus.as_ref()?.flow()?.clone(); + Some(crate::flow::FlowExternalPresence { + name: entry.name, + color_rgb: entry.color_rgb, + sheet: focus.sheet, + cell: focus.cell, + editing: focus.editing, + caret: focus.caret, + }) + }) + .collect(); + flow.update(cx, |flow, cx| flow.set_external_presences(presences, cx)); + return; + } let Some(runtime) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) else { tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text runtime is attached"); return; @@ -220,7 +262,8 @@ impl CollabSession { .filter(|entry| entry.key != self_key) .filter_map(|entry| { entry - .selection + .focus + .and_then(|focus| focus.text().cloned()) .map(|selection| RuntimePresenceCaretRequest { selection, color_rgb: entry.color_rgb, diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index 5326112f..e201151a 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -44,11 +44,15 @@ pub enum FlowEditorEvent { Changed, ActiveCellChanged(Option), ActiveSheetChanged(Option), + /// The caret moved inside the active cell's editor (presence refresh only — + /// no document change). + CellSelectionChanged, } /// One remote peer's board focus, resolved for rendering: which sheet they -/// are on, which cell they focus (outline + name chip), and whether they are -/// editing inside it. +/// are on, which cell they focus (outline + name chip), whether they are +/// editing inside it, and — because cells are real CRDT text — their exact +/// caret as encoded Loro cursors (forwarded into the open cell editor). #[derive(Clone, Debug, PartialEq)] pub struct FlowExternalPresence { pub name: String, @@ -56,6 +60,7 @@ pub struct FlowExternalPresence { pub sheet: Option, pub cell: Option, pub editing: bool, + pub caret: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -788,10 +793,91 @@ impl FlowEditor { pub fn set_external_presences(&mut self, presences: Vec, cx: &mut Context) { if self.external_presences != presences { self.external_presences = presences; + self.sync_external_cell_carets(cx); cx.notify(); } } + /// The local user's board focus for the presence channel: active sheet/cell, + /// whether the cell editor is open, and the exact caret encoded as Loro + /// cursors under the flow gate. + pub fn presence_focus(&self, cx: &gpui::App) -> flowstate_collab::presence::FlowPresenceFocus { + let caret = self.active_cell.and_then(|cell| { + let editor = self.cell_editors.get(&cell)?; + let selection = editor.read(cx).selection().clone(); + self.handle.presence_selection(cell, &selection) + }); + flowstate_collab::presence::FlowPresenceFocus { + sheet: self.active_sheet, + cell: self.active_cell, + editing: self + .active_cell + .is_some_and(|cell| self.cell_editors.contains_key(&cell)), + caret, + } + } + + /// The open editor for a cell, if any (presence caret forwarding + tests). + pub fn cell_editor(&self, cell: CellId) -> Option> { + self.cell_editors.get(&cell).cloned() + } + + /// The peer-color outline for a cell a remote peer focuses (render + gate + /// test hook). First matching peer wins; roster order is deterministic. + pub fn presence_for_cell(&self, cell: CellId) -> Option<&FlowExternalPresence> { + self + .external_presences + .iter() + .find(|presence| presence.cell == Some(cell)) + } + + /// Peers focused on a sheet OTHER than the local active one — rendered as + /// colored dots on that sheet's switcher row. + pub fn presence_dots_for_sheet(&self, sheet: SheetId) -> Vec { + self + .external_presences + .iter() + .filter(|presence| presence.sheet == Some(sheet)) + .map(|presence| presence.color_rgb) + .collect() + } + + /// Forward remote peers' carets into the matching OPEN cell editors via the + /// existing external-caret API (exact positions — the cursors resolve under + /// the flow gate against the cell's current text). + pub(super) fn sync_external_cell_carets(&mut self, cx: &mut Context) { + for (cell_id, editor) in &self.cell_editors { + let mut carets = Vec::new(); + let mut selections = Vec::new(); + for presence in &self.external_presences { + if presence.cell != Some(*cell_id) { + continue; + } + let Some(caret) = presence.caret.as_ref() else { + continue; + }; + let Some((head, anchor)) = self.handle.resolve_presence_selection(*cell_id, caret) else { + continue; + }; + carets.push(crate::rich_text_element::ExternalCaret { + offset: head, + visual_gravity: crate::rich_text_element::VisualGravity::Neutral, + color_rgb: presence.color_rgb, + }); + if anchor != head { + selections.push(crate::rich_text_element::ExternalSelection { + selection: crate::rich_text_element::EditorSelection::range(anchor, head), + color_rgb: presence.color_rgb, + }); + } + } + editor.update(cx, |editor, cx| { + editor.set_external_carets(carets, cx); + editor.set_external_selections(selections, cx); + }); + } + } + pub fn external_presences(&self) -> &[FlowExternalPresence] { &self.external_presences } @@ -1055,6 +1141,12 @@ impl FlowEditor { } let cell_editor = self.cell_editors.get(&id).cloned(); let reply_editor = cx.entity().clone(); + // Remote peer focus: peer-color outline + name chip on the card + // (spec Part C presence rendering). + let presence = self.presence_for_cell(id).cloned(); + let presence_color = presence + .as_ref() + .map(|presence| gpui::Hsla::from(gpui::rgb(presence.color_rgb))); div() .w_full() .flex_col() @@ -1084,8 +1176,10 @@ impl FlowEditor { .min_h(px(54.0 * zoom)) .p(px(10.0 * zoom)) .rounded(px(6.0 * zoom)) - .border(px(1.0 * zoom)) - .border_color(if active == Some(id) { + .border(px(if presence_color.is_some() { 2.0 * zoom } else { 1.0 * zoom })) + .border_color(if let Some(color) = presence_color { + color + } else if active == Some(id) { side_palette.active } else { side_color.opacity(0.56) @@ -1161,6 +1255,26 @@ impl FlowEditor { })) }) .when(is_ghost, |this| this.opacity(0.5).border_dashed().border_color(side_palette.active)) + .when_some(presence, |this, presence| { + let color = gpui::Hsla::from(gpui::rgb(presence.color_rgb)); + let chip: SharedString = if presence.editing { + format!("{} ✎", presence.name).into() + } else { + presence.name.clone().into() + }; + this.child( + div() + .absolute() + .top(px(-10.0 * zoom)) + .left(px(6.0 * zoom)) + .px(px(6.0 * zoom)) + .rounded(px(4.0 * zoom)) + .text_size(px(10.0 * zoom)) + .bg(color) + .text_color(gpui::white()) + .child(chip), + ) + }) .child( div() .id(("flow-cell-drag-handle", id.as_u128() as u64)) diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs index c16e72ce..1dc14454 100644 --- a/crates/flowstate/src/flow/editor/cell_editing.rs +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -56,13 +56,21 @@ impl FlowEditor { // the BOARD side current (summary refreshes ride the board stream) and // the dirty flag honest — caret-only notifies are ignored. let subscription = cx.subscribe(&editor, move |flow, _editor, event: &crate::rich_text_element::EditorEvent, cx| { - if matches!(event, crate::rich_text_element::EditorEvent::Changed { .. }) { - flow.sync_board_from_handle(cx); - if !flow.dirty { - flow.dirty = true; - cx.emit(super::FlowEditorEvent::Changed); - } - cx.notify(); + match event { + crate::rich_text_element::EditorEvent::Changed { .. } => { + flow.sync_board_from_handle(cx); + if !flow.dirty { + flow.dirty = true; + cx.emit(super::FlowEditorEvent::Changed); + } + cx.notify(); + }, + // Caret motion inside the cell rides the presence channel (step 11) — + // the session subscription debounces + republishes own presence. + crate::rich_text_element::EditorEvent::SelectionChanged { .. } => { + cx.emit(super::FlowEditorEvent::CellSelectionChanged); + }, + _ => {}, } }); self.cell_editors.insert(cell_id, editor); @@ -78,6 +86,9 @@ impl FlowEditor { for editor in self.cell_editors.values() { editor.update(cx, |editor, cx| editor.sync_projection_from_authority(cx)); } + // Remote edits move text under peers' carets; re-resolve them against the + // refreshed cell state. + self.sync_external_cell_carets(cx); } pub(super) fn refresh_active_cell_theme(&mut self, cx: &mut Context) { diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index 57d8b9ab..d2992962 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -3,7 +3,7 @@ pub mod editor; pub mod panel; pub mod ribbon; -pub use editor::{AnnotationTool, FlowEditor, FlowEditorEvent}; +pub use editor::{AnnotationTool, FlowEditor, FlowEditorEvent, FlowExternalPresence}; pub use panel::FlowPanel; pub use ribbon::FlowRibbon; diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs index a1600d8a..212e4d55 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs @@ -51,6 +51,102 @@ fn find_keystroke_is_absorbed_without_panic(cx: &mut TestAppContext) { cx.run_until_parked(); } +/// Build-order step 11 gate: synthetic remote presence → peer-color outline +/// on the focused cell + a sheet-switcher dot, and the peer's exact caret +/// (encoded Loro cursors) forwarded into the open cell editor. +#[gpui::test] +fn flow_external_presence_outline_and_cell_caret_forwarding(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + + // One sheet + one cell, typed through the authority, editor open. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + editor.create_sheet(cx); + editor.add_first_argument(cx); + }); + }); + cx.run_until_parked(); + let (sheet_id, cell_id) = h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + (board.sheets[0].id, board.sheets[0].cells[0].id) + }); + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let authority = editor.handle().cell_authority(cell_id); + let projection = editor.handle().open_cell(cell_id).expect("open cell"); + use crate::rich_text_element::{InsertTextIntent, LocalIntent, LocalWriteAuthority, TextAnchor}; + let authority: std::sync::Arc = authority; + authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(projection.ids.paragraph_ids[0], 0), + text: "presence target".into(), + style_override: None, + })) + .expect("cell typing commits"); + editor.activate_cell(cell_id, cx); + }); + }); + cx.run_until_parked(); + + // The "remote" caret: byte offset 8 of paragraph 0, encoded to the exact + // Loro cursor bytes a peer's presence frame would carry. + use crate::rich_text_element::{DocumentOffset, EditorSelection}; + let at = DocumentOffset { paragraph: 0, byte: 8 }; + let caret = h.update(cx, |_, _, cx| { + flow + .read(cx) + .handle() + .presence_selection(cell_id, &EditorSelection::range(at, at)) + .expect("encode presence caret") + }); + + let presence = crate::flow::FlowExternalPresence { + name: "Remote Peer".into(), + color_rgb: 0x00ff_0000, + sheet: Some(sheet_id), + cell: Some(cell_id), + editing: true, + caret: Some(caret), + }; + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_external_presences(vec![presence], cx)); + }); + cx.run_until_parked(); + + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert_eq!( + editor.presence_for_cell(cell_id).map(|presence| presence.color_rgb), + Some(0x00ff_0000), + "remote focus must outline the focused cell in the peer color", + ); + assert_eq!( + editor.presence_dots_for_sheet(sheet_id), + vec![0x00ff_0000], + "the peer's sheet must show a presence dot", + ); + let cell_editor = editor.cell_editor(cell_id).expect("open cell editor"); + let carets = cell_editor.read(cx).external_carets_for_paragraph(0); + assert_eq!(carets.len(), 1, "the peer caret must be forwarded into the open cell editor"); + assert_eq!(carets[0].color_rgb, 0x00ff_0000); + assert_eq!(carets[0].offset, at, "the forwarded caret resolves to the exact encoded position"); + }); + + // Clearing presence (leave semantics) clears the forwarded carets too. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_external_presences(Vec::new(), cx)); + }); + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert!(editor.presence_for_cell(cell_id).is_none()); + let cell_editor = editor.cell_editor(cell_id).expect("open cell editor"); + assert!(cell_editor.read(cx).external_carets_for_paragraph(0).is_empty()); + }); +} + /// Build-order step 8 gate: solo flow edit/save/undo THROUGH the gated /// authority path — sheet + cell structural intents, cell typing via the /// per-cell `FlowCellAuthority`, `.fl0` v2 save round trip, drag commit via diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index 751321b9..b9624a3a 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -169,8 +169,10 @@ impl Workspace { let toggle_editor = editor.clone(); let activate_editor = editor.clone(); let key = sheet_id.as_u128() as usize; - rows.push( - render_sidebar_tree_row( + // Remote peers focused on this sheet show as colored dots on its + // switcher row (spec Part C presence rendering). + let presence_dots = editor.read(cx).presence_dots_for_sheet(sheet_id); + let sheet_row = render_sidebar_tree_row( SidebarTreeRow { row_id: ("flow-outline-sheet", key), toggle_id: ("flow-outline-sheet-toggle", key), @@ -200,9 +202,29 @@ impl Workspace { }, window, cx, - ) - .into_any_element(), - ); + ); + rows.push(if presence_dots.is_empty() { + sheet_row.into_any_element() + } else { + div() + .relative() + .child(sheet_row) + .child( + div() + .absolute() + .right_2() + .top_2() + .flex() + .gap_1() + .children(presence_dots.into_iter().map(|color| { + div() + .size(px(8.0)) + .rounded_full() + .bg(Hsla::from(gpui::rgb(color))) + })), + ) + .into_any_element() + }); if !sheet_expanded { continue; } From 87d46274ff1f5147a497f1b91d0578ffb5e47f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:49:17 +0000 Subject: [PATCH 24/24] =?UTF-8?q?flowstate:=20parity=20tail=20=E2=80=94=20?= =?UTF-8?q?Dropbox=20flow=20mirror,=20flow=20soak,=20hotpath=20measures,?= =?UTF-8?q?=20clippy/fmt=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropbox: bound .fl0 files mirror on save/autosave via sync_bound_flow_file — raw v2 container upload against the stored rev; on conflict the remote merges through the flow I/O service (CRDT import), the local file rewrites, and the merged container re-uploads; a flow.meta document-id gate prevents cross-document clobbering, same as the .db8 checkpoint path. flowstate-soak gains a flow_soak bin: two peers drive structural intents + same-cell typing through the real gated write path with per-round update exchange, then prove convergence (boards byte-equal and equal a fresh materialization, no typed character lost) and print per-stage wall-clock distributions. Hotpath instrumentation lands on apply_flow_intent and the board/cell materializers (flowstate-flow grows the standard hotpath feature wiring). The v1-era flowstate-flow examples are ported to the v2 schema (ergonomics_fixture writes canonical-order boards and asserts a defect-free materialization; dump_flow reads .fl0 v2). cargo clippy --workspace --all-targets and cargo fmt --check are clean; new integration test files follow the repo's cfg(test)-module convention; JoinedDocumentPayload boxes its rich-text arm. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UFUYkPyp7R8gZEMA6EUjgW --- Cargo.lock | 4 + crates/flowstate-collab/Cargo.toml | 6 +- .../src/flow/cell_authority.rs | 25 +- crates/flowstate-collab/src/flow/cell_text.rs | 38 +- crates/flowstate-collab/src/flow/commit.rs | 55 +- crates/flowstate-collab/src/flow/flow_io.rs | 24 +- crates/flowstate-collab/src/flow/handle.rs | 55 +- crates/flowstate-collab/src/flow/runtime.rs | 31 +- crates/flowstate-collab/src/net/direct.rs | 2 +- crates/flowstate-collab/src/ticket.rs | 10 +- .../tests/flow_cell_text_fuzz.rs | 612 +++++------ .../tests/flow_convergence.rs | 491 ++++----- crates/flowstate-collab/tests/flow_io_pump.rs | 407 ++++---- crates/flowstate-collab/tests/flow_runtime.rs | 953 +++++++++--------- .../flowstate-collab/tests/swarm_loopback.rs | 21 +- crates/flowstate-document/src/lib.rs | 5 +- .../flowstate-document/src/loro_projection.rs | 47 +- crates/flowstate-flow/Cargo.toml | 6 + crates/flowstate-flow/examples/dump_flow.rs | 49 +- .../examples/ergonomics_fixture.rs | 141 +-- crates/flowstate-flow/src/loro_projection.rs | 47 +- crates/flowstate-flow/src/loro_schema.rs | 14 +- crates/flowstate-flow/src/persistence.rs | 8 +- crates/flowstate-flow/tests/v2_board.rs | 840 +++++++-------- crates/flowstate-soak/Cargo.toml | 23 +- crates/flowstate-soak/src/bin/flow_soak.rs | 281 ++++++ .../src/collab/dropbox_checkpoint.rs | 70 ++ crates/flowstate/src/collab/manager.rs | 17 +- crates/flowstate/src/collab/session.rs | 48 +- crates/flowstate/src/collab/session_io.rs | 28 +- .../flowstate/src/collab/session_presence.rs | 17 +- crates/flowstate/src/flow/editor.rs | 20 +- .../flowstate/src/flow/editor/annotation.rs | 13 +- crates/flowstate/src/flow/editor/telemetry.rs | 15 +- .../src/workspace/workspace/collab.rs | 2 +- .../src/workspace/workspace/documents.rs | 46 +- .../workspace/headless_tests/collab_glue.rs | 15 +- .../headless_tests/flows_and_keystrokes.rs | 49 +- 38 files changed, 2620 insertions(+), 1915 deletions(-) create mode 100644 crates/flowstate-soak/src/bin/flow_soak.rs diff --git a/Cargo.lock b/Cargo.lock index da87221e..31ea554c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3285,6 +3285,7 @@ dependencies = [ "anyhow", "flowstate-document", "gpui-flowtext", + "hotpath", "loro", "postcard", "rustc-hash 2.1.2", @@ -3302,9 +3303,12 @@ dependencies = [ "flowstate-collab", "flowstate-document", "flowstate-docx", + "flowstate-flow", + "gpui-flowtext", "hotpath", "loro", "postcard", + "uuid", ] [[package]] diff --git a/crates/flowstate-collab/Cargo.toml b/crates/flowstate-collab/Cargo.toml index 05420303..6f24127a 100644 --- a/crates/flowstate-collab/Cargo.toml +++ b/crates/flowstate-collab/Cargo.toml @@ -34,9 +34,9 @@ uuid = { workspace = true } zstd.workspace = true [features] -hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "gpui-flowtext/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] +hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "flowstate-flow/hotpath", "gpui-flowtext/hotpath"] +hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "flowstate-flow/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] +hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "flowstate-flow/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/flowstate-collab/src/flow/cell_authority.rs b/crates/flowstate-collab/src/flow/cell_authority.rs index 51868a99..0d716fba 100644 --- a/crates/flowstate-collab/src/flow/cell_authority.rs +++ b/crates/flowstate-collab/src/flow/cell_authority.rs @@ -87,10 +87,7 @@ impl LocalWriteAuthority for FlowCellAuthority { fn apply(&self, intent: LocalIntent) -> Result { let outcome = self .handle - .apply(FlowIntent::CellText { - cell_id: self.cell, - intent, - }) + .apply(FlowIntent::CellText { cell_id: self.cell, intent }) .map_err(Self::map_rejection)?; if !outcome.changed { return Err(WriteRejected::EmptyIntent); @@ -116,7 +113,10 @@ impl LocalWriteAuthority for FlowCellAuthority { fn undo(&self) -> Result { let outcome = self.handle.undo().map_err(Self::map_rejection)?; - let selection = outcome.meta.as_ref().and_then(|meta| self.selection_from_meta(meta)); + let selection = outcome + .meta + .as_ref() + .and_then(|meta| self.selection_from_meta(meta)); Ok(UndoOutcome { applied: outcome.applied, selection, @@ -125,7 +125,10 @@ impl LocalWriteAuthority for FlowCellAuthority { fn redo(&self) -> Result { let outcome = self.handle.redo().map_err(Self::map_rejection)?; - let selection = outcome.meta.as_ref().and_then(|meta| self.selection_from_meta(meta)); + let selection = outcome + .meta + .as_ref() + .and_then(|meta| self.selection_from_meta(meta)); Ok(UndoOutcome { applied: outcome.applied, selection, @@ -148,7 +151,10 @@ impl LocalWriteAuthority for FlowCellAuthority { } fn canonical_projection(&self) -> Result { - self.handle.open_cell(self.cell).map_err(Self::map_rejection) + self + .handle + .open_cell(self.cell) + .map_err(Self::map_rejection) } fn encode_selection_anchor(&self, selection: &EditorSelection, editor_frontier: &[u8]) -> Option<(Vec, Vec)> { @@ -156,7 +162,7 @@ impl LocalWriteAuthority for FlowCellAuthority { let selection = selection.clone(); let editor_frontier = editor_frontier.to_vec(); let handle = Arc::clone(&self.handle); - let encoded = self + self .handle .with_runtime(GateHolder::Presence, move |runtime| { // Only encode while the editor is in sync with the core (an undrained @@ -180,8 +186,7 @@ impl LocalWriteAuthority for FlowCellAuthority { }); Some((head.encode(), anchor.encode())) }) - .ok()?; - encoded + .ok()? } fn resolve_selection_anchor( diff --git a/crates/flowstate-collab/src/flow/cell_text.rs b/crates/flowstate-collab/src/flow/cell_text.rs index 0fd8f414..f0284a3f 100644 --- a/crates/flowstate-collab/src/flow/cell_text.rs +++ b/crates/flowstate-collab/src/flow/cell_text.rs @@ -8,7 +8,9 @@ //! path owns the single commit and the compensation law. use anyhow::{Context as _, Result}; -use flowstate_document::loro_schema::{MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH}; +use flowstate_document::loro_schema::{ + MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, +}; use flowstate_document::paragraph_ids_by_boundary_in; use flowstate_flow::format::CellId; use flowstate_flow::loro_schema::{cell_paragraph_registry, cell_text, write_paragraph_record}; @@ -100,7 +102,10 @@ impl CellTextContext { .checked_sub(1)?; let (start, end) = self.paragraph_span(ix)?; let clamped = unicode.clamp(start, end); - let byte = self.chars[start..clamped].iter().map(|ch| ch.len_utf8()).sum(); + let byte = self.chars[start..clamped] + .iter() + .map(|ch| ch.len_utf8()) + .sum(); Some(gpui_flowtext::DocumentOffset { paragraph: ix, byte }) } @@ -291,7 +296,9 @@ pub(crate) fn resolve_cell_plan( }, LocalIntent::ReplaceMatches(intent) => { if intent.replacement.contains('\n') { - return Err(FlowWriteRejected::StructureViolation("replacement must not contain structural characters".into())); + return Err(FlowWriteRejected::StructureViolation( + "replacement must not contain structural characters".into(), + )); } let mut matches: Vec<(usize, usize, Option)> = Vec::new(); for candidate in &intent.matches { @@ -300,12 +307,17 @@ pub(crate) fn resolve_cell_plan( }; // Same-paragraph, non-collapsed: a range crossing a boundary was moved // by concurrent edits — skip, don't reject. - if start == end || ctx.boundaries.iter().any(|boundary| (start..end).contains(boundary)) { + if start == end + || ctx + .boundaries + .iter() + .any(|boundary| (start..end).contains(boundary)) + { continue; } matches.push((start, end, candidate.styles)); } - matches.sort_by(|a, b| b.0.cmp(&a.0)); + matches.sort_by_key(|entry| std::cmp::Reverse(entry.0)); // Descending by start: drop a match that overlaps the previously kept // (higher-positioned) one — its end reaches past that match's start. matches.dedup_by(|current, previous| current.1 > previous.0); @@ -377,14 +389,20 @@ pub(crate) fn execute_cell_plan(ctx: &CellTextContext, plan: &CellPlan) -> Resul Ok(Some(*start)) }, CellPlan::Split { at, style } => { - ctx.text.insert(*at, "\n").context("inserting cell split boundary")?; + ctx + .text + .insert(*at, "\n") + .context("inserting cell split boundary")?; ctx .text .mark(*at..*at + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) .context("marking cell split style")?; // Sentinel hygiene: strip expand-`After` run keys off the boundary. for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { - ctx.text.unmark(*at..*at + 1, key).context("unmarking split boundary run keys")?; + ctx + .text + .unmark(*at..*at + 1, key) + .context("unmarking split boundary run keys")?; } let key = format!("paragraph.{}", Uuid::new_v4().as_u128()); write_paragraph_record(&ctx.registry, &ctx.text, &key, *at).context("writing split paragraph record")?; @@ -477,11 +495,13 @@ pub(crate) fn execute_cell_plan(ctx: &CellTextContext, plan: &CellPlan) -> Resul } } -/// Unmark-then-set over a range (the .db8 `mark_run_styles` law: SetMarks is a +/// Unmark-then-set over a range (the .db8 `mark_run_styles` law: `SetMarks` is a /// full restatement of the range's run styles). fn replace_run_styles(text: &LoroText, range: std::ops::Range, styles: RunStyles) -> Result<()> { for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { - text.unmark(range.clone(), key).context("unmarking run style key")?; + text + .unmark(range.clone(), key) + .context("unmarking run style key")?; } if let gpui_flowtext::RunSemanticStyle::Custom(slot) = styles.semantic { text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; diff --git a/crates/flowstate-collab/src/flow/commit.rs b/crates/flowstate-collab/src/flow/commit.rs index ccdae311..5ff75b02 100644 --- a/crates/flowstate-collab/src/flow/commit.rs +++ b/crates/flowstate-collab/src/flow/commit.rs @@ -31,6 +31,7 @@ use super::runtime::{FlowRuntime, FlowWriteOutcome, FlowWriteRejected}; use crate::crdt_runtime::cursor_for_boundary; /// Execute one flow intent against the gate-held runtime. +#[hotpath::measure] pub(crate) fn apply_flow_intent(core: &mut FlowRuntime, intent: &FlowIntent) -> Result { let class = intent.class(); let doc = core.doc().clone(); @@ -112,7 +113,7 @@ impl From for ExecuteError { } } -fn sheet_of<'a>(core: &'a FlowRuntime, sheet_id: SheetId) -> Result<&'a Sheet, FlowWriteRejected> { +fn sheet_of(core: &FlowRuntime, sheet_id: SheetId) -> Result<&Sheet, FlowWriteRejected> { core .board_ref() .sheet(sheet_id) @@ -162,7 +163,10 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> for cell in cells { core.close_cell(cell); } - core.board_mut().sheets.retain(|sheet| sheet.id != *sheet_id); + core + .board_mut() + .sheets + .retain(|sheet| sheet.id != *sheet_id); Ok(Some(Derived::Board)) }, FlowIntent::MoveSheet { sheet_id, target_index } => { @@ -276,7 +280,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> let sheet_map = loro_schema::sheet_map(doc, *sheet_id).context("resolving sheet map")?; // Column/parent rewrites for changed subtree members (LWW fields). for cell in &target.cells { - let old = before.cell(cell.id).context("moved cell missing from pre-state")?; + let old = before + .cell(cell.id) + .context("moved cell missing from pre-state")?; if old.column_id != cell.column_id || old.parent_id != cell.parent_id { let map = loro_schema::cell_map(doc, cell.id).context("resolving moved cell map")?; if old.column_id != cell.column_id { @@ -289,7 +295,11 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> } // Order-list moves ONLY (never a cell map/text write): minimal `mov` // sequence transforming the live order into the target order. - let target_ids: Vec = target.cells.iter().map(|cell| cell.id.to_string()).collect(); + let target_ids: Vec = target + .cells + .iter() + .map(|cell| cell.id.to_string()) + .collect(); apply_order_diff(&sheet_map, &target_ids).context("applying order-list moves")?; if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { *sheet = target; @@ -298,7 +308,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> }, FlowIntent::SetCellStruck { sheet_id, cell_id, struck } => { let sheet = sheet_of(core, *sheet_id)?; - let cell = sheet.cell(*cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let cell = sheet + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; if cell.summary.struck == *struck { return Ok(None); } @@ -322,7 +334,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> }, FlowIntent::EnsureCellEditable { sheet_id, cell_id } => { let sheet = sheet_of(core, *sheet_id)?; - let cell = sheet.cell(*cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let cell = sheet + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; if cell.summary.uses_summary_projection { return Ok(None); } @@ -360,7 +374,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> } loro_schema::put_annotation(doc, stroke).context("writing annotation stroke")?; if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { - sheet.annotations.retain(|existing| existing.id != stroke.id); + sheet + .annotations + .retain(|existing| existing.id != stroke.id); sheet.annotations.push(stroke.clone()); sheet.annotations.sort_by_key(|stroke| stroke.id); } @@ -406,7 +422,9 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> } for sheet in &mut core.board_mut().sheets { if sheet_id.is_none_or(|target| sheet.id == target) { - sheet.annotations.retain(|stroke| &stroke.originator != originator); + sheet + .annotations + .retain(|stroke| &stroke.originator != originator); } } Ok(Some(Derived::Board)) @@ -429,10 +447,7 @@ fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> }; let plan = cell_text::resolve_cell_plan(doc, &ctx, ¶graph_ids, intent)?; let caret = cell_text::execute_cell_plan(&ctx, &plan).context("executing cell text plan")?; - Ok(Some(Derived::Content { - cell: *cell_id, - caret, - })) + Ok(Some(Derived::Content { cell: *cell_id, caret })) }, } } @@ -447,7 +462,9 @@ fn resolve_placement( placement: &CellPlacement, ) -> Result<(usize, usize, Option), FlowWriteRejected> { let board = core.board_ref(); - let sheet = board.sheet(sheet_id).ok_or(FlowWriteRejected::UnknownSheet(sheet_id))?; + let sheet = board + .sheet(sheet_id) + .ok_or(FlowWriteRejected::UnknownSheet(sheet_id))?; match placement { CellPlacement::ColumnEnd { column_index } => { if *column_index >= columns.len() { @@ -493,7 +510,9 @@ fn resolve_placement( Ok((column, insertion, source.parent_id)) }, CellPlacement::FirstResponseTo { parent } | CellPlacement::ResponseTo { parent } => { - let parent_cell = sheet.cell(*parent).ok_or(FlowWriteRejected::UnknownCell(*parent))?; + let parent_cell = sheet + .cell(*parent) + .ok_or(FlowWriteRejected::UnknownCell(*parent))?; let parent_column = columns .iter() .position(|column| *column == parent_cell.column_id) @@ -556,13 +575,17 @@ pub(crate) fn reconcile_order_list(order: &loro::LoroMovableList, current: &[Str } } for index in doomed.into_iter().rev() { - order.delete(index, 1).context("dropping dead order entry")?; + order + .delete(index, 1) + .context("dropping dead order entry")?; working.remove(index); } // Missing target entries append (their canonical position lands via `mov`). for id in target { if !working.iter().any(|entry| entry == id) { - order.insert(working.len(), id.as_str()).context("appending missing order entry")?; + order + .insert(working.len(), id.as_str()) + .context("appending missing order entry")?; working.push(id.clone()); } } diff --git a/crates/flowstate-collab/src/flow/flow_io.rs b/crates/flowstate-collab/src/flow/flow_io.rs index b25067c7..f94ece9f 100644 --- a/crates/flowstate-collab/src/flow/flow_io.rs +++ b/crates/flowstate-collab/src/flow/flow_io.rs @@ -112,11 +112,15 @@ impl FlowIoHandle { /// Drain committed-but-unpublished local events. pub async fn pump_publish(&self) -> Result> { - self.request(|reply| FlowIoRequest::PumpPublish { reply }).await + self + .request(|reply| FlowIoRequest::PumpPublish { reply }) + .await } pub async fn board_snapshot(&self) -> Result { - self.request(|reply| FlowIoRequest::BoardSnapshot { reply }).await + self + .request(|reply| FlowIoRequest::BoardSnapshot { reply }) + .await } pub async fn oplog_version_vector(&self) -> Result> { @@ -132,15 +136,21 @@ impl FlowIoHandle { } pub async fn snapshot_bytes(&self) -> Result> { - self.request(|reply| FlowIoRequest::SnapshotBytes { reply }).await + self + .request(|reply| FlowIoRequest::SnapshotBytes { reply }) + .await } pub async fn save_to(&self, path: PathBuf) -> Result<()> { - self.request(|reply| FlowIoRequest::SaveTo { path, reply }).await + self + .request(|reply| FlowIoRequest::SaveTo { path, reply }) + .await } pub async fn encode_bytes(&self) -> Result> { - self.request(|reply| FlowIoRequest::EncodeBytes { reply }).await + self + .request(|reply| FlowIoRequest::EncodeBytes { reply }) + .await } } @@ -232,9 +242,7 @@ fn io_loop(core: &Arc>, receiver: &Receiver send_reply( &reply, - gate_call(core, GateHolder::ExportUpdates, |runtime| { - Ok(runtime.oplog_version_vector().encode()) - }), + gate_call(core, GateHolder::ExportUpdates, |runtime| Ok(runtime.oplog_version_vector().encode())), ), FlowIoRequest::ExportUpdatesFor { remote_vv, reply } => { let result = VersionVector::decode(&remote_vv) diff --git a/crates/flowstate-collab/src/flow/handle.rs b/crates/flowstate-collab/src/flow/handle.rs index 1398eaf8..cae6da07 100644 --- a/crates/flowstate-collab/src/flow/handle.rs +++ b/crates/flowstate-collab/src/flow/handle.rs @@ -51,7 +51,10 @@ impl FlowDocHandle { } fn lock(&self, holder: GateHolder) -> Result, FlowWriteRejected> { - self.core.lock(holder).map_err(|_| FlowWriteRejected::GatePoisoned) + self + .core + .lock(holder) + .map_err(|_| FlowWriteRejected::GatePoisoned) } /// Update the board context riding future undo items (focus restoration). @@ -169,12 +172,11 @@ impl FlowDocHandle { /// Whole-doc Loro snapshot under a brief gate hold (solo save path; the /// collaboration save goes through the flow I/O service's fork-off-gate). pub fn snapshot(&self) -> Result, FlowWriteRejected> { - self - .with_runtime(GateHolder::DocumentService, |runtime| { - runtime - .snapshot() - .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) - })? + self.with_runtime(GateHolder::DocumentService, |runtime| { + runtime + .snapshot() + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + })? } // ---- Presence (spec Part C, step 11) ---------------------------------------- @@ -182,28 +184,22 @@ impl FlowDocHandle { /// Encode a cell-local editor selection as presence cursor bytes (exact Loro /// cursors — no offset guessing). Advisory: presence rides its own channel, /// so unlike undo-context capture there is no editor-frontier requirement. - pub fn presence_selection( - &self, - cell: CellId, - selection: &gpui_flowtext::EditorSelection, - ) -> Option { + pub fn presence_selection(&self, cell: CellId, selection: &gpui_flowtext::EditorSelection) -> Option { use crate::presence::{PresenceSelection, SelectionAffinity, SelectionEndpoint, VisualGravity}; let selection = selection.clone(); self .with_runtime(GateHolder::Presence, move |runtime| { let ctx = super::cell_text::CellTextContext::resolve(runtime.doc(), cell).ok()?; - let endpoint = |offset: gpui_flowtext::DocumentOffset, - affinity: SelectionAffinity, - gravity: VisualGravity| - -> Option { - let unicode = ctx.unicode_for_offset(offset)?; - let cursor = crate::crdt_runtime::cursor_for_boundary(&ctx.text, unicode, affinity)?; - Some(SelectionEndpoint { - cursor: cursor.encode(), - affinity, - visual_gravity: gravity, - }) - }; + let endpoint = + |offset: gpui_flowtext::DocumentOffset, affinity: SelectionAffinity, gravity: VisualGravity| -> Option { + let unicode = ctx.unicode_for_offset(offset)?; + let cursor = crate::crdt_runtime::cursor_for_boundary(&ctx.text, unicode, affinity)?; + Some(SelectionEndpoint { + cursor: cursor.encode(), + affinity, + visual_gravity: gravity, + }) + }; Some(PresenceSelection { anchor: endpoint( selection.anchor, @@ -262,20 +258,13 @@ impl FlowDocHandle { // ---- Gate-held reads shared with the cell authority ------------------------ - pub(crate) fn with_runtime( - &self, - holder: GateHolder, - read: impl FnOnce(&mut FlowRuntime) -> T, - ) -> Result { + pub(crate) fn with_runtime(&self, holder: GateHolder, read: impl FnOnce(&mut FlowRuntime) -> T) -> Result { let mut guard = self.lock(holder)?; Ok(read(&mut guard)) } } -fn selection_direction( - anchor: gpui_flowtext::DocumentOffset, - head: gpui_flowtext::DocumentOffset, -) -> crate::presence::SelectionDirection { +fn selection_direction(anchor: gpui_flowtext::DocumentOffset, head: gpui_flowtext::DocumentOffset) -> crate::presence::SelectionDirection { match anchor.cmp(&head) { std::cmp::Ordering::Less => crate::presence::SelectionDirection::Forward, std::cmp::Ordering::Greater => crate::presence::SelectionDirection::Backward, diff --git a/crates/flowstate-collab/src/flow/runtime.rs b/crates/flowstate-collab/src/flow/runtime.rs index 7479332b..00b9b443 100644 --- a/crates/flowstate-collab/src/flow/runtime.rs +++ b/crates/flowstate-collab/src/flow/runtime.rs @@ -366,7 +366,11 @@ impl FlowRuntime { fn set_board_summary(&mut self, cell: CellId, summary: CellSummary) -> bool { for sheet in &mut self.board.sheets { - if let Some(found) = sheet.cells.iter_mut().find(|candidate| candidate.id == cell) { + if let Some(found) = sheet + .cells + .iter_mut() + .find(|candidate| candidate.id == cell) + { if found.summary == summary { return false; } @@ -408,12 +412,11 @@ impl FlowRuntime { pub(crate) fn audit_board_against_rebuild(&self, class: &str) { match materialize_board(&self.doc) { Ok((fresh, _)) => { - if fresh != self.board { - panic!( - "flow intent '{class}' left the maintained board out of sync with a fresh materialization:\n{}", - board_divergence(&self.board, &fresh) - ); - } + assert!( + fresh == self.board, + "flow intent '{class}' left the maintained board out of sync with a fresh materialization:\n{}", + board_divergence(&self.board, &fresh) + ); }, Err(error) => panic!("flow board audit rematerialization failed after '{class}': {error:#}"), } @@ -429,7 +432,10 @@ impl FlowRuntime { self.import_batches_served += 1; let frontier_before = self.doc.state_frontiers(); for chunk in chunks { - self.doc.import(chunk).context("importing remote flow update")?; + self + .doc + .import(chunk) + .context("importing remote flow update")?; } if self.doc.state_frontiers() == frontier_before { return Ok(()); @@ -578,12 +584,7 @@ fn install_undo_meta_callbacks(undo: &UndoManager, state: &Arc> = meta - .cursors - .iter() - .map(|entry| entry.cursor.encode()) - .collect(); - let LoroValue::Binary(bytes) = meta.value else { + let LoroValue::Binary(bytes) = meta.value.clone() else { return; }; match postcard::from_bytes::(bytes.as_ref()) { @@ -591,7 +592,7 @@ fn install_undo_meta_callbacks(undo: &UndoManager, state: &Arc Option<&'static str> { - if version < PROTOCOL_VERSION { - Some("this collaboration invite is outdated and can no longer be joined — ask for a fresh invite") - } else if version > PROTOCOL_VERSION { - Some("this collaboration invite was created by a newer Flowstate — update Flowstate to join") - } else { - None + match version.cmp(&PROTOCOL_VERSION) { + std::cmp::Ordering::Less => Some("this collaboration invite is outdated and can no longer be joined — ask for a fresh invite"), + std::cmp::Ordering::Greater => Some("this collaboration invite was created by a newer Flowstate — update Flowstate to join"), + std::cmp::Ordering::Equal => None, } } diff --git a/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs index 5e770d55..9635f423 100644 --- a/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs +++ b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs @@ -8,339 +8,367 @@ //! Peers drive the REAL write path: `FlowCellAuthority` → `FlowIntent::CellText` //! → gated commit, exactly what an attached `RichTextEditor` does. -use std::sync::Arc; +#[cfg(test)] +mod tests { + use std::sync::Arc; -use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; -use flowstate_collab::local_write::WriteGate; -use flowstate_flow::format::{CellId, FlowFormat, SheetId}; -use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; -use gpui_flowtext::local_intents::{ - DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, LocalWriteAuthority as _, SetMarksIntent, SplitParagraphIntent, - TextAnchor, WriteRejected, -}; -use gpui_flowtext::{ParagraphStyle, RunStyles}; -use uuid::Uuid; + use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; + use flowstate_collab::local_write::WriteGate; + use flowstate_flow::format::{CellId, FlowFormat, SheetId}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; + use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, LocalWriteAuthority as _, SetMarksIntent, SplitParagraphIntent, + TextAnchor, WriteRejected, + }; + use gpui_flowtext::{ParagraphStyle, RunStyles}; + use uuid::Uuid; -struct Rng(u64); + struct Rng(u64); -impl Rng { - fn new(seed: u64) -> Self { - Self(seed.max(1)) - } + impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } - fn next(&mut self) -> u64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - self.0 - } + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } - fn below(&mut self, bound: usize) -> usize { - (self.next() % bound.max(1) as u64) as usize + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } } -} -struct Peer { - handle: Arc, - authority: Arc, - #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] - gate: Arc>, -} - -impl Peer { - fn oplog_vv(&self) -> loro::VersionVector { - self.handle.with_test_runtime(|runtime| runtime.oplog_version_vector()) + struct Peer { + handle: Arc, + authority: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, } - fn cell_text(&self, cell: CellId) -> String { - self.handle.with_test_runtime(|runtime| { - let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); - rows - .blocks - .iter() - .filter_map(|block| match block { - gpui_flowtext::InputBlock::Paragraph(paragraph) => { - Some(paragraph.runs.iter().map(|run| run.text.as_str()).collect::()) - }, - _ => None, - }) - .collect::>() - .join("\n") - }) + impl Peer { + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } + + fn cell_text(&self, cell: CellId) -> String { + self.handle.with_test_runtime(|runtime| { + let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); + rows + .blocks + .iter() + .filter_map(|block| match block { + gpui_flowtext::InputBlock::Paragraph(paragraph) => Some( + paragraph + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + ), + _ => None, + }) + .collect::>() + .join("\n") + }) + } } -} -fn sync_all(peers: &[Peer]) { - for _ in 0..2 { - for a in 0..peers.len() { - for b in 0..peers.len() { - if a == b { - continue; - } - let vv = peers[b].oplog_vv(); - if let Ok(bytes) = peers[a].handle.with_test_runtime(|runtime| runtime.export_updates_for(&vv)) - && !bytes.is_empty() - { - peers[b] + fn sync_all(peers: &[Peer]) { + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + if let Ok(bytes) = peers[a] .handle - .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) - .expect("import"); + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } } } } } -} -fn spawn(peers_n: usize) -> (Vec, SheetId, CellId) { - let format = FlowFormat::policy_debate(); - let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("seed runtime")); - let sheet = Uuid::new_v4(); - let cell = Uuid::new_v4(); - seed_handle - .apply(FlowIntent::CreateSheet { - sheet_id: sheet, - name: "Text fuzz".into(), - sheet_type_id: format.sheet_types[0].id, - }) - .expect("sheet"); - for column_index in 0..2 { - // A second cell so reorders have something to move against. + fn spawn(peers_n: usize) -> (Vec, SheetId, CellId) { + let format = FlowFormat::policy_debate(); + let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("seed runtime")); + let sheet = Uuid::new_v4(); + let cell = Uuid::new_v4(); seed_handle - .apply(FlowIntent::AddCell { + .apply(FlowIntent::CreateSheet { sheet_id: sheet, - cell_id: if column_index == 0 { cell } else { Uuid::new_v4() }, - placement: CellPlacement::ColumnEnd { column_index: 0 }, - seed: CellSeed::Empty, + name: "Text fuzz".into(), + sheet_type_id: format.sheet_types[0].id, }) - .expect("cell"); + .expect("sheet"); + for column_index in 0..2 { + // A second cell so reorders have something to move against. + seed_handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: if column_index == 0 { cell } else { Uuid::new_v4() }, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .expect("cell"); + } + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let peers = (0..peers_n) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer")); + let authority = handle.cell_authority(cell); + let _ = handle.open_cell(cell).expect("open cell"); + Peer { handle, authority, gate } + }) + .collect(); + (peers, sheet, cell) } - let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); - let peers = (0..peers_n) - .map(|_| { - let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer")); - let authority = handle.cell_authority(cell); - let _ = handle.open_cell(cell).expect("open cell"); - Peer { handle, authority, gate } - }) - .collect(); - (peers, sheet, cell) -} -/// One random cell-text intent through the peer's authority. Expected -/// rejections (anchors moved by concurrent edits) are fine. -fn random_text_intent(rng: &mut Rng, peer: &Peer, step: usize) -> Result<(), WriteRejected> { - let projection = peer.authority.canonical_projection()?; - let paragraph_count = projection.paragraphs.len(); - let pick_anchor = |rng: &mut Rng| { - let ix = rng.below(paragraph_count); - let id = projection.ids.paragraph_ids[ix]; - let len = flowstate_document::paragraph_text_len(&projection.paragraphs[ix]); - TextAnchor::new(id, rng.below(len + 1)) - }; - let intent = match rng.below(6) { - 0 | 1 => LocalIntent::InsertText(InsertTextIntent { - at: pick_anchor(rng), - text: format!("w{step} "), - style_override: None, - }), - 2 => LocalIntent::DeleteRange(DeleteRangeIntent { - start: pick_anchor(rng), - end: pick_anchor(rng), - }), - 3 => LocalIntent::SplitParagraph(SplitParagraphIntent { - at: pick_anchor(rng), - inherited_style: ParagraphStyle::Normal, - }), - 4 => { - if paragraph_count < 2 { - return Ok(()); - } - let first_ix = rng.below(paragraph_count - 1); - LocalIntent::JoinParagraphs(JoinParagraphsIntent { - first: projection.ids.paragraph_ids[first_ix], - second: projection.ids.paragraph_ids[first_ix + 1], - }) - }, - _ => LocalIntent::SetMarks(SetMarksIntent { - start: pick_anchor(rng), - end: pick_anchor(rng), - styles: RunStyles { - strikethrough: rng.below(2) == 0, - direct_underline: rng.below(2) == 0, - ..RunStyles::default() + /// One random cell-text intent through the peer's authority. Expected + /// rejections (anchors moved by concurrent edits) are fine. + fn random_text_intent(rng: &mut Rng, peer: &Peer, step: usize) -> Result<(), WriteRejected> { + let projection = peer.authority.canonical_projection()?; + let paragraph_count = projection.paragraphs.len(); + let pick_anchor = |rng: &mut Rng| { + let ix = rng.below(paragraph_count); + let id = projection.ids.paragraph_ids[ix]; + let len = flowstate_document::paragraph_text_len(&projection.paragraphs[ix]); + TextAnchor::new(id, rng.below(len + 1)) + }; + let intent = match rng.below(6) { + 0 | 1 => LocalIntent::InsertText(InsertTextIntent { + at: pick_anchor(rng), + text: format!("w{step} "), + style_override: None, + }), + 2 => LocalIntent::DeleteRange(DeleteRangeIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + }), + 3 => LocalIntent::SplitParagraph(SplitParagraphIntent { + at: pick_anchor(rng), + inherited_style: ParagraphStyle::Normal, + }), + 4 => { + if paragraph_count < 2 { + return Ok(()); + } + let first_ix = rng.below(paragraph_count - 1); + LocalIntent::JoinParagraphs(JoinParagraphsIntent { + first: projection.ids.paragraph_ids[first_ix], + second: projection.ids.paragraph_ids[first_ix + 1], + }) }, - }), - }; - peer.authority.apply(intent).map(|_| ()) -} + _ => LocalIntent::SetMarks(SetMarksIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + styles: RunStyles { + strikethrough: rng.below(2) == 0, + direct_underline: rng.below(2) == 0, + ..RunStyles::default() + }, + }), + }; + peer.authority.apply(intent).map(|_| ()) + } -fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { - let mut rng = Rng::new(seed); - let (peers, sheet, cell) = spawn(peers_n); + fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let (peers, sheet, cell) = spawn(peers_n); - for round in 0..rounds { - for op in 0..ops_per_round { - let peer_ix = rng.below(peers_n); - let step = round * ops_per_round + op; - // Interleave the occasional structural reorder with the typing storm — - // the reorder-vs-edit headline arm. - if rng.below(8) == 0 { - let board = peers[peer_ix].handle.board_projection().expect("board"); - let cells: Vec = board.sheet(sheet).expect("sheet").cells.iter().map(|cell| cell.id).collect(); - if cells.len() > 1 { - let target = cells[rng.below(cells.len())]; - if target != cell { - let _ = peers[peer_ix].handle.apply(FlowIntent::MoveCellSubtree { - sheet_id: sheet, - cell_id: cell, - drop: if rng.below(2) == 0 { - FlowDropIntent::BeforeSibling(target) - } else { - FlowDropIntent::AfterSibling(target) - }, - }); + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + // Interleave the occasional structural reorder with the typing storm — + // the reorder-vs-edit headline arm. + if rng.below(8) == 0 { + let board = peers[peer_ix].handle.board_projection().expect("board"); + let cells: Vec = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|cell| cell.id) + .collect(); + if cells.len() > 1 { + let target = cells[rng.below(cells.len())]; + if target != cell { + let _ = peers[peer_ix].handle.apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: if rng.below(2) == 0 { + FlowDropIntent::BeforeSibling(target) + } else { + FlowDropIntent::AfterSibling(target) + }, + }); + } } } + match random_text_intent(&mut rng, &peers[peer_ix], step) { + Ok(()) | Err(WriteRejected::EmptyIntent | WriteRejected::StructureViolation(_) | WriteRejected::UnresolvedParagraph(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } } - match random_text_intent(&mut rng, &peers[peer_ix], step) { - Ok(()) - | Err(WriteRejected::EmptyIntent | WriteRejected::StructureViolation(_) | WriteRejected::UnresolvedParagraph(_)) => {}, - Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), - } - } - sync_all(&peers); + sync_all(&peers); - let reference_vv = peers[0].oplog_vv(); - let reference_text = peers[0].cell_text(cell); - let reference_board = peers[0].handle.board_projection().expect("board"); - for (ix, peer) in peers.iter().enumerate().skip(1) { - assert_eq!( - peer.oplog_vv(), - reference_vv, - "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" - ); - assert_eq!( - peer.cell_text(cell), - reference_text, - "seed {seed} round {round}: peer {ix} cell text diverged" - ); - assert_eq!( - peer.handle.board_projection().expect("board"), - reference_board, - "seed {seed} round {round}: peer {ix} board diverged" - ); + let reference_vv = peers[0].oplog_vv(); + let reference_text = peers[0].cell_text(cell); + let reference_board = peers[0].handle.board_projection().expect("board"); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!( + peer.cell_text(cell), + reference_text, + "seed {seed} round {round}: peer {ix} cell text diverged" + ); + assert_eq!( + peer.handle.board_projection().expect("board"), + reference_board, + "seed {seed} round {round}: peer {ix} board diverged" + ); + } } } -} -/// THE headline pair, deterministic form: peer A types while peer B reorders -/// the same cell; both edits survive the merge exactly. -#[test] -fn reorder_never_loses_a_concurrent_keystroke() { - let (peers, sheet, cell) = spawn(2); - let projection = peers[0].authority.canonical_projection().expect("projection"); - let paragraph = projection.ids.paragraph_ids[0]; + /// THE headline pair, deterministic form: peer A types while peer B reorders + /// the same cell; both edits survive the merge exactly. + #[test] + fn reorder_never_loses_a_concurrent_keystroke() { + let (peers, sheet, cell) = spawn(2); + let projection = peers[0] + .authority + .canonical_projection() + .expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; - peers[0] - .authority - .apply(LocalIntent::InsertText(InsertTextIntent { - at: TextAnchor::new(paragraph, 0), - text: "surviving keystrokes".into(), - style_override: None, - })) - .expect("typing"); - let board = peers[1].handle.board_projection().expect("board"); - let other = board - .sheet(sheet) - .expect("sheet") - .cells - .iter() - .map(|candidate| candidate.id) - .find(|id| *id != cell) - .expect("other cell"); - peers[1] - .handle - .apply(FlowIntent::MoveCellSubtree { - sheet_id: sheet, - cell_id: cell, - drop: FlowDropIntent::AfterSibling(other), - }) - .expect("reorder"); + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "surviving keystrokes".into(), + style_override: None, + })) + .expect("typing"); + let board = peers[1].handle.board_projection().expect("board"); + let other = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|candidate| candidate.id) + .find(|id| *id != cell) + .expect("other cell"); + peers[1] + .handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: FlowDropIntent::AfterSibling(other), + }) + .expect("reorder"); - sync_all(&peers); - for peer in &peers { - assert_eq!(peer.cell_text(cell), "surviving keystrokes"); - let board = peer.handle.board_projection().expect("board"); - let order: Vec = board.sheet(sheet).expect("sheet").cells.iter().map(|cell| cell.id).collect(); - assert_eq!(order, vec![other, cell], "the reorder survived alongside the keystrokes"); + sync_all(&peers); + for peer in &peers { + assert_eq!(peer.cell_text(cell), "surviving keystrokes"); + let board = peer.handle.board_projection().expect("board"); + let order: Vec = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|cell| cell.id) + .collect(); + assert_eq!(order, vec![other, cell], "the reorder survived alongside the keystrokes"); + } } -} -#[test] -fn concurrent_same_cell_edits_interleave() { - let (peers, _sheet, cell) = spawn(2); - let projection = peers[0].authority.canonical_projection().expect("projection"); - let paragraph = projection.ids.paragraph_ids[0]; - peers[0] - .authority - .apply(LocalIntent::InsertText(InsertTextIntent { - at: TextAnchor::new(paragraph, 0), - text: "left".into(), - style_override: None, - })) - .expect("peer 0 types"); - peers[1] - .authority - .apply(LocalIntent::InsertText(InsertTextIntent { - at: TextAnchor::new(paragraph, 0), - text: "right".into(), - style_override: None, - })) - .expect("peer 1 types"); - sync_all(&peers); - let merged = peers[0].cell_text(cell); - assert_eq!(merged, peers[1].cell_text(cell)); - assert!( - merged.contains("left") && merged.contains("right"), - "both peers' characters survive char-level merge: {merged:?}" - ); -} + #[test] + fn concurrent_same_cell_edits_interleave() { + let (peers, _sheet, cell) = spawn(2); + let projection = peers[0] + .authority + .canonical_projection() + .expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "left".into(), + style_override: None, + })) + .expect("peer 0 types"); + peers[1] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "right".into(), + style_override: None, + })) + .expect("peer 1 types"); + sync_all(&peers); + let merged = peers[0].cell_text(cell); + assert_eq!(merged, peers[1].cell_text(cell)); + assert!( + merged.contains("left") && merged.contains("right"), + "both peers' characters survive char-level merge: {merged:?}" + ); + } -#[test] -fn two_peer_cell_text_fuzz_converges() { - for seed in [1, 7, 42, 505, 1337, 8738] { - run_fuzz(seed, 2, 6, 12); + #[test] + fn two_peer_cell_text_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } } -} -#[test] -fn three_peer_cell_text_fuzz_converges() { - for seed in [3, 99, 4242, 20260707] { - run_fuzz(seed, 3, 5, 10); + #[test] + fn three_peer_cell_text_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } } -} -/// Long-horizon soak (10k+ text intents across the sweep), ignored by default. -#[test] -#[ignore = "long-horizon soak — run via `heaven.sh soak`"] -fn flow_cell_text_soak() { - let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500); - let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8); - let ops: usize = std::env::var("FUZZ_SOAK_OPS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(16); - for seed in 1..=seeds { - let peers = 2 + (seed % 2) as usize; - run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); - if seed % 25 == 0 { - eprintln!("[soak] flow_cell_text {seed}/{seeds}"); + /// Long-horizon soak (10k+ text intents across the sweep), ignored by default. + #[test] + #[ignore = "long-horizon soak — run via `heaven.sh soak`"] + fn flow_cell_text_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 2) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_cell_text {seed}/{seeds}"); + } } } } diff --git a/crates/flowstate-collab/tests/flow_convergence.rs b/crates/flowstate-collab/tests/flow_convergence.rs index 433d2302..3861bea9 100644 --- a/crates/flowstate-collab/tests/flow_convergence.rs +++ b/crates/flowstate-collab/tests/flow_convergence.rs @@ -5,295 +5,302 @@ //! derivation law). Mirrors the .db8 `intent_fuzz` harness shape: fixed CI //! seeds + an ignored long-horizon soak. -use std::sync::Arc; +#[cfg(test)] +mod tests { + use std::sync::Arc; -use flowstate_collab::flow::{FlowDocHandle, FlowRuntime, FlowWriteRejected}; -use flowstate_collab::local_write::WriteGate; -use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; -use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; -use flowstate_flow::loro_projection::materialize_board; -use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; -use uuid::Uuid; + use flowstate_collab::flow::{FlowDocHandle, FlowRuntime, FlowWriteRejected}; + use flowstate_collab::local_write::WriteGate; + use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; + use flowstate_flow::loro_projection::materialize_board; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use uuid::Uuid; -struct Rng(u64); + struct Rng(u64); -impl Rng { - fn new(seed: u64) -> Self { - Self(seed.max(1)) - } + impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } - fn next(&mut self) -> u64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - self.0 + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } } - fn below(&mut self, bound: usize) -> usize { - (self.next() % bound.max(1) as u64) as usize + struct Peer { + handle: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, } -} -struct Peer { - handle: Arc, - #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] - gate: Arc>, -} + impl Peer { + fn board(&self) -> flowstate_flow::projection::FlowBoardProjection { + self.handle.board_projection().expect("board") + } -impl Peer { - fn board(&self) -> flowstate_flow::projection::FlowBoardProjection { - self.handle.board_projection().expect("board") + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } } - fn oplog_vv(&self) -> loro::VersionVector { - self.handle.with_test_runtime(|runtime| runtime.oplog_version_vector()) + fn spawn_peers(count: usize, format: &FlowFormat, base_sheet: SheetId) -> Vec { + let seed_runtime = FlowRuntime::new(format).expect("seed runtime"); + let (seed_handle, _seed_gate) = FlowDocHandle::new(seed_runtime); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: base_sheet, + name: "Fuzz".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("seed sheet"); + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("seed snapshot")); + (0..count) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer runtime")); + Peer { handle, gate } + }) + .collect() } -} -fn spawn_peers(count: usize, format: &FlowFormat, base_sheet: SheetId) -> Vec { - let seed_runtime = FlowRuntime::new(format).expect("seed runtime"); - let (seed_handle, _seed_gate) = FlowDocHandle::new(seed_runtime); - seed_handle - .apply(FlowIntent::CreateSheet { - sheet_id: base_sheet, - name: "Fuzz".into(), - sheet_type_id: format.sheet_types[0].id, - }) - .expect("seed sheet"); - let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("seed snapshot")); - (0..count) - .map(|_| { - let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer runtime")); - Peer { handle, gate } - }) - .collect() -} - -fn sync_all(peers: &[Peer]) { - // Full mesh, two passes → quiescent for any peer count used here. - for _ in 0..2 { - for a in 0..peers.len() { - for b in 0..peers.len() { - if a == b { - continue; - } - let vv = peers[b].oplog_vv(); - let bytes = peers[a].handle.with_test_runtime(|runtime| runtime.export_updates_for(&vv)); - if let Ok(bytes) = bytes - && !bytes.is_empty() - { - peers[b] + fn sync_all(peers: &[Peer]) { + // Full mesh, two passes → quiescent for any peer count used here. + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + let bytes = peers[a] .handle - .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) - .expect("import"); + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)); + if let Ok(bytes) = bytes + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } } } } } -} - -fn stroke(sheet: SheetId, originator: &str, rng: &mut Rng) -> AnnotationStroke { - AnnotationStroke { - id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), - sheet_id: sheet, - originator: AnnotationOriginator(originator.into()), - points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], - style: StrokeStyle { - color_rgba: 0xff00_00ff, - width: 1.0, - opacity: 1.0, - }, - bbox: BoardRect::default(), - } -} -/// One random structural intent against `peer`. Rejections from racing -/// deletions are expected; anything else is a bug. -fn random_intent(rng: &mut Rng, peer: &Peer, sheet: SheetId, step: usize) -> Result<(), FlowWriteRejected> { - let board = peer.board(); - let cells: Vec = board - .sheet(sheet) - .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) - .unwrap_or_default(); - let pick = |rng: &mut Rng, cells: &[CellId]| cells.get(rng.below(cells.len())).copied(); - let intent = match rng.below(10) { - 0 => FlowIntent::AddCell { + fn stroke(sheet: SheetId, originator: &str, rng: &mut Rng) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), sheet_id: sheet, - cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), - placement: CellPlacement::ColumnEnd { - column_index: rng.below(3), + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 1.0, + opacity: 1.0, }, - seed: CellSeed::Empty, - }, - 1 => match pick(rng, &cells) { - Some(parent) => FlowIntent::AddCell { + bbox: BoardRect::default(), + } + } + + /// One random structural intent against `peer`. Rejections from racing + /// deletions are expected; anything else is a bug. + fn random_intent(rng: &mut Rng, peer: &Peer, sheet: SheetId, step: usize) -> Result<(), FlowWriteRejected> { + let board = peer.board(); + let cells: Vec = board + .sheet(sheet) + .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) + .unwrap_or_default(); + let pick = |rng: &mut Rng, cells: &[CellId]| cells.get(rng.below(cells.len())).copied(); + let intent = match rng.below(10) { + 0 => FlowIntent::AddCell { sheet_id: sheet, cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), - placement: if rng.below(2) == 0 { - CellPlacement::ResponseTo { parent } - } else { - CellPlacement::FirstResponseTo { parent } - }, + placement: CellPlacement::ColumnEnd { column_index: rng.below(3) }, seed: CellSeed::Empty, }, - None => return Ok(()), - }, - 2 => match pick(rng, &cells) { - Some(of) => FlowIntent::AddCell { - sheet_id: sheet, - cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), - placement: CellPlacement::Sibling { - of, - position: if rng.below(2) == 0 { - RelativePosition::Before + 1 => match pick(rng, &cells) { + Some(parent) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: if rng.below(2) == 0 { + CellPlacement::ResponseTo { parent } } else { - RelativePosition::After + CellPlacement::FirstResponseTo { parent } }, + seed: CellSeed::Empty, }, - seed: CellSeed::Empty, + None => return Ok(()), }, - None => return Ok(()), - }, - 3 => match pick(rng, &cells) { - Some(cell_id) => FlowIntent::DeleteCell { sheet_id: sheet, cell_id }, - None => return Ok(()), - }, - 4 => match (pick(rng, &cells), pick(rng, &cells)) { - (Some(cell_id), Some(target)) if cell_id != target => FlowIntent::MoveCellSubtree { - sheet_id: sheet, - cell_id, - drop: match rng.below(5) { - 0 => FlowDropIntent::BeforeSibling(target), - 1 => FlowDropIntent::AfterSibling(target), - 2 => FlowDropIntent::FirstChildOf(target), - 3 => FlowDropIntent::LastChildOf(target), - _ => FlowDropIntent::RootInColumn { - column_index: rng.below(3), - insertion_index: rng.below(cells.len() + 1), + 2 => match pick(rng, &cells) { + Some(of) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: CellPlacement::Sibling { + of, + position: if rng.below(2) == 0 { + RelativePosition::Before + } else { + RelativePosition::After + }, }, + seed: CellSeed::Empty, }, + None => return Ok(()), + }, + 3 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::DeleteCell { sheet_id: sheet, cell_id }, + None => return Ok(()), }, - _ => return Ok(()), - }, - 5 => match pick(rng, &cells) { - Some(cell_id) => FlowIntent::SetCellStruck { + 4 => match (pick(rng, &cells), pick(rng, &cells)) { + (Some(cell_id), Some(target)) if cell_id != target => FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id, + drop: match rng.below(5) { + 0 => FlowDropIntent::BeforeSibling(target), + 1 => FlowDropIntent::AfterSibling(target), + 2 => FlowDropIntent::FirstChildOf(target), + 3 => FlowDropIntent::LastChildOf(target), + _ => FlowDropIntent::RootInColumn { + column_index: rng.below(3), + insertion_index: rng.below(cells.len() + 1), + }, + }, + }, + _ => return Ok(()), + }, + 5 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::SetCellStruck { + sheet_id: sheet, + cell_id, + struck: rng.below(2) == 0, + }, + None => return Ok(()), + }, + 6 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::ReplaceCellContent { + sheet_id: sheet, + cell_id, + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Custom(3), + runs: vec![InputRun { + text: format!("edit {step}"), + styles: RunStyles::default(), + }], + }], + }, + None => return Ok(()), + }, + 7 => FlowIntent::AddAnnotation { sheet_id: sheet, - cell_id, - struck: rng.below(2) == 0, + stroke: stroke(sheet, "fuzz", rng), + }, + 8 => FlowIntent::ClearAnnotations { + sheet_id: Some(sheet), + originator: AnnotationOriginator("fuzz".into()), }, - None => return Ok(()), - }, - 6 => match pick(rng, &cells) { - Some(cell_id) => FlowIntent::ReplaceCellContent { + _ => FlowIntent::RenameSheet { sheet_id: sheet, - cell_id, - paragraphs: vec![InputParagraph { - style: ParagraphStyle::Custom(3), - runs: vec![InputRun { - text: format!("edit {step}"), - styles: RunStyles::default(), - }], - }], + name: format!("Fuzz {step}"), }, - None => return Ok(()), - }, - 7 => FlowIntent::AddAnnotation { - sheet_id: sheet, - stroke: stroke(sheet, "fuzz", rng), - }, - 8 => FlowIntent::ClearAnnotations { - sheet_id: Some(sheet), - originator: AnnotationOriginator("fuzz".into()), - }, - _ => FlowIntent::RenameSheet { - sheet_id: sheet, - name: format!("Fuzz {step}"), - }, - }; - peer.handle.apply(intent).map(|_| ()) -} + }; + peer.handle.apply(intent).map(|_| ()) + } -fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { - let mut rng = Rng::new(seed); - let format = FlowFormat::policy_debate(); - let sheet = Uuid::new_v4(); - let peers = spawn_peers(peers_n, &format, sheet); + fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let format = FlowFormat::policy_debate(); + let sheet = Uuid::new_v4(); + let peers = spawn_peers(peers_n, &format, sheet); - for round in 0..rounds { - for op in 0..ops_per_round { - let peer_ix = rng.below(peers_n); - let step = round * ops_per_round + op; - match random_intent(&mut rng, &peers[peer_ix], sheet, step) { - Ok(()) - | Err(FlowWriteRejected::EmptyIntent | FlowWriteRejected::StructureViolation(_)) - | Err(FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_)) => {}, - Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + match random_intent(&mut rng, &peers[peer_ix], sheet, step) { + Ok(()) + | Err(FlowWriteRejected::EmptyIntent | FlowWriteRejected::StructureViolation(_)) + | Err(FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } } - } - sync_all(&peers); + sync_all(&peers); - // Self-consistency: maintained board == fresh materialization. - for (ix, peer) in peers.iter().enumerate() { - let maintained = peer.board(); - let fresh = peer - .handle - .with_test_runtime(|runtime| materialize_board(runtime.doc()).expect("fresh materialize").0); - assert_eq!( - maintained, fresh, - "seed {seed} round {round}: peer {ix} maintained board deviates from its own canonical state" - ); - } - // Cross-peer convergence. - let reference = peers[0].board(); - let reference_vv = peers[0].oplog_vv(); - for (ix, peer) in peers.iter().enumerate().skip(1) { - assert_eq!( - peer.oplog_vv(), - reference_vv, - "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" - ); - assert_eq!(peer.board(), reference, "seed {seed} round {round}: peer {ix} board diverged"); + // Self-consistency: maintained board == fresh materialization. + for (ix, peer) in peers.iter().enumerate() { + let maintained = peer.board(); + let fresh = peer.handle.with_test_runtime(|runtime| { + materialize_board(runtime.doc()) + .expect("fresh materialize") + .0 + }); + assert_eq!( + maintained, fresh, + "seed {seed} round {round}: peer {ix} maintained board deviates from its own canonical state" + ); + } + // Cross-peer convergence. + let reference = peers[0].board(); + let reference_vv = peers[0].oplog_vv(); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!(peer.board(), reference, "seed {seed} round {round}: peer {ix} board diverged"); + } } } -} -#[test] -fn two_peer_flow_fuzz_converges() { - for seed in [1, 7, 42, 505, 1337, 8738] { - run_fuzz(seed, 2, 6, 12); + #[test] + fn two_peer_flow_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } } -} -#[test] -fn three_peer_flow_fuzz_converges() { - for seed in [3, 99, 4242, 20260707] { - run_fuzz(seed, 3, 5, 10); + #[test] + fn three_peer_flow_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } } -} -/// Long-horizon randomized soak (10k+ intents across the sweep), ignored by -/// default like the .db8 soaks — run via `heaven.sh soak`; tune with -/// `FUZZ_SOAK_SEEDS`/`FUZZ_SOAK_ROUNDS`/`FUZZ_SOAK_OPS`. -#[test] -#[ignore = "long-horizon soak — run via `heaven.sh soak`"] -fn flow_fuzz_soak() { - let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500); - let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8); - let ops: usize = std::env::var("FUZZ_SOAK_OPS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(16); - for seed in 1..=seeds { - let peers = 2 + (seed % 3) as usize; - run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); - if seed % 25 == 0 { - eprintln!("[soak] flow_convergence {seed}/{seeds}"); + /// Long-horizon randomized soak (10k+ intents across the sweep), ignored by + /// default like the .db8 soaks — run via `heaven.sh soak`; tune with + /// `FUZZ_SOAK_SEEDS`/`FUZZ_SOAK_ROUNDS`/`FUZZ_SOAK_OPS`. + #[test] + #[ignore = "long-horizon soak — run via `heaven.sh soak`"] + fn flow_fuzz_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 3) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_convergence {seed}/{seeds}"); + } } } } diff --git a/crates/flowstate-collab/tests/flow_io_pump.rs b/crates/flowstate-collab/tests/flow_io_pump.rs index be78b6de..3a2d3737 100644 --- a/crates/flowstate-collab/tests/flow_io_pump.rs +++ b/crates/flowstate-collab/tests/flow_io_pump.rs @@ -3,224 +3,227 @@ //! gate, pathological delivery order, deferred non-import ordering, the //! exactly-once publish pump, and the `.fl0` save/recovery byte paths. -use std::sync::Arc; - -use anyhow::Result; -use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}; -use flowstate_collab::local_write::{GateHolder, WriteGate}; -use flowstate_flow::format::{FlowFormat, SheetId}; -use flowstate_flow::intents::FlowIntent; -use uuid::Uuid; - -type FlowGate = Arc>; - -/// A based (source handle, receiver gate) pair plus `edits` sheet renames -/// exported as one delta blob PER EDIT (the network drip shape) and the -/// source's final board. -fn drip_pair(edits: usize) -> Result<(flowstate_flow::projection::FlowBoardProjection, Vec>, FlowGate)> { - let format = FlowFormat::policy_debate(); - let (source_handle, source_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); - let sheet: SheetId = Uuid::new_v4(); - source_handle - .apply(FlowIntent::CreateSheet { - sheet_id: sheet, - name: "Drip".into(), - sheet_type_id: format.sheet_types[0].id, - }) - .map_err(|error| anyhow::anyhow!("seed sheet rejected: {error}"))?; - let snapshot = source_handle.with_test_runtime(|runtime| runtime.snapshot())?; - let (_receiver_handle, receiver_gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); - - let mut blobs = Vec::with_capacity(edits); - for edit_ix in 0..edits { - let vv_before = source_gate - .lock(GateHolder::ExportUpdates) - .expect("gate healthy") - .oplog_version_vector(); +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use anyhow::Result; + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}; + use flowstate_collab::local_write::{GateHolder, WriteGate}; + use flowstate_flow::format::{FlowFormat, SheetId}; + use flowstate_flow::intents::FlowIntent; + use uuid::Uuid; + + type FlowGate = Arc>; + + /// A based (source handle, receiver gate) pair plus `edits` sheet renames + /// exported as one delta blob PER EDIT (the network drip shape) and the + /// source's final board. + fn drip_pair(edits: usize) -> Result<(flowstate_flow::projection::FlowBoardProjection, Vec>, FlowGate)> { + let format = FlowFormat::policy_debate(); + let (source_handle, source_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let sheet: SheetId = Uuid::new_v4(); source_handle - .apply(FlowIntent::RenameSheet { + .apply(FlowIntent::CreateSheet { sheet_id: sheet, - name: format!("Drip {edit_ix}"), + name: "Drip".into(), + sheet_type_id: format.sheet_types[0].id, }) - .map_err(|error| anyhow::anyhow!("drip edit {edit_ix} rejected: {error}"))?; - let blob = source_gate + .map_err(|error| anyhow::anyhow!("seed sheet rejected: {error}"))?; + let snapshot = source_handle.with_test_runtime(|runtime| runtime.snapshot())?; + let (_receiver_handle, receiver_gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); + + let mut blobs = Vec::with_capacity(edits); + for edit_ix in 0..edits { + let vv_before = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .oplog_version_vector(); + source_handle + .apply(FlowIntent::RenameSheet { + sheet_id: sheet, + name: format!("Drip {edit_ix}"), + }) + .map_err(|error| anyhow::anyhow!("drip edit {edit_ix} rejected: {error}"))?; + let blob = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .export_updates_for(&vv_before)?; + blobs.push(blob); + } + let final_board = source_handle + .board_projection() + .map_err(|error| anyhow::anyhow!("board: {error}"))?; + Ok((final_board, blobs, receiver_gate)) + } + + fn board(gate: &FlowGate) -> flowstate_flow::projection::FlowBoardProjection { + gate .lock(GateHolder::ExportUpdates) .expect("gate healthy") - .export_updates_for(&vv_before)?; - blobs.push(blob); + .board_ref() + .clone() } - let final_board = source_handle - .board_projection() - .map_err(|error| anyhow::anyhow!("board: {error}"))?; - Ok((final_board, blobs, receiver_gate)) -} - -fn board(gate: &FlowGate) -> flowstate_flow::projection::FlowBoardProjection { - gate - .lock(GateHolder::ExportUpdates) - .expect("gate healthy") - .board_ref() - .clone() -} -fn import_batches(gate: &FlowGate) -> u64 { - gate - .lock(GateHolder::ExportUpdates) - .expect("gate healthy") - .import_batches_served() -} - -/// A 40-blob drip fired while the gate is HELD must coalesce into a few -/// batched imports, every reply must arrive, and the receiver must converge. -#[test] -fn held_gate_drip_coalesces_into_bounded_batches() -> Result<()> { - const EDITS: usize = 40; - let (source_board, blobs, gate) = drip_pair(EDITS)?; - let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); - - let batches_before = import_batches(&gate); - let hold = gate - .lock(GateHolder::DocumentService) - .expect("gate healthy"); - #[allow(clippy::needless_collect, reason = "spawn-all-then-join-all; lazy iteration would serialize the burst")] - let senders: Vec<_> = blobs - .iter() - .map(|blob| { - let io_import = io.clone(); - let blob = blob.clone(); - std::thread::spawn(move || pollster::block_on(io_import.import_remote_update(blob))) - }) - .collect(); - std::thread::sleep(std::time::Duration::from_millis(200)); - drop(hold); - let replies: Vec<_> = senders - .into_iter() - .map(|sender| sender.join().expect("sender join")) - .collect(); - - assert_eq!(replies.len(), EDITS); - for (blob_ix, reply) in replies.iter().enumerate() { - assert!(reply.is_ok(), "blob {blob_ix} reply failed: {reply:?}"); + fn import_batches(gate: &FlowGate) -> u64 { + gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .import_batches_served() } - assert_eq!(board(&gate), source_board, "receiver did not converge to the source board"); - let batches = import_batches(&gate) - batches_before; - assert!( - (3..=8).contains(&batches), - "expected 3..=8 coalesced batches for {EDITS} blobs behind a held gate, saw {batches}" - ); - Ok(()) -} -/// Duplicated + reversed blobs: Loro buffers out-of-order updates and -/// re-applies duplicates as no-ops — every reply Ok, receiver converges. -#[test] -fn duplicated_and_reversed_drip_converges() -> Result<()> { - const EDITS: usize = 12; - let (source_board, blobs, gate) = drip_pair(EDITS)?; - let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); - - let mut schedule: Vec> = Vec::new(); - for blob in blobs.iter().rev() { - schedule.push(blob.clone()); - schedule.push(blob.clone()); - } - let replies = pollster::block_on(async { - let mut replies = Vec::new(); - for blob in schedule { - replies.push(io.import_remote_update(blob).await); + /// A 40-blob drip fired while the gate is HELD must coalesce into a few + /// batched imports, every reply must arrive, and the receiver must converge. + #[test] + fn held_gate_drip_coalesces_into_bounded_batches() -> Result<()> { + const EDITS: usize = 40; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let batches_before = import_batches(&gate); + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + #[allow(clippy::needless_collect, reason = "spawn-all-then-join-all; lazy iteration would serialize the burst")] + let senders: Vec<_> = blobs + .iter() + .map(|blob| { + let io_import = io.clone(); + let blob = blob.clone(); + std::thread::spawn(move || pollster::block_on(io_import.import_remote_update(blob))) + }) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(hold); + let replies: Vec<_> = senders + .into_iter() + .map(|sender| sender.join().expect("sender join")) + .collect(); + + assert_eq!(replies.len(), EDITS); + for (blob_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "blob {blob_ix} reply failed: {reply:?}"); } - replies - }); - for (delivery_ix, reply) in replies.iter().enumerate() { - assert!(reply.is_ok(), "delivery {delivery_ix} reply failed: {reply:?}"); + assert_eq!(board(&gate), source_board, "receiver did not converge to the source board"); + let batches = import_batches(&gate) - batches_before; + assert!( + (3..=8).contains(&batches), + "expected 3..=8 coalesced batches for {EDITS} blobs behind a held gate, saw {batches}" + ); + Ok(()) } - assert_eq!(board(&gate), source_board, "receiver did not converge under duplicated reversed delivery"); - Ok(()) -} -/// A non-import request interleaved mid-burst is DEFERRED, not dropped. -#[test] -fn non_import_request_interleaved_with_burst_still_replies() -> Result<()> { - const EDITS: usize = 10; - let (source_board, blobs, gate) = drip_pair(EDITS)?; - let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); - - let hold = gate - .lock(GateHolder::DocumentService) - .expect("gate healthy"); - let io_imports = io.clone(); - let import_thread = std::thread::spawn(move || { - pollster::block_on(async move { + /// Duplicated + reversed blobs: Loro buffers out-of-order updates and + /// re-applies duplicates as no-ops — every reply Ok, receiver converges. + #[test] + fn duplicated_and_reversed_drip_converges() -> Result<()> { + const EDITS: usize = 12; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let mut schedule: Vec> = Vec::new(); + for blob in blobs.iter().rev() { + schedule.push(blob.clone()); + schedule.push(blob.clone()); + } + let replies = pollster::block_on(async { let mut replies = Vec::new(); - for blob in blobs { - replies.push(io_imports.import_remote_update(blob).await); + for blob in schedule { + replies.push(io.import_remote_update(blob).await); } replies - }) - }); - let io_snapshot = io.clone(); - let snapshot_thread = std::thread::spawn(move || pollster::block_on(io_snapshot.board_snapshot())); - std::thread::sleep(std::time::Duration::from_millis(100)); - drop(hold); - - let replies = import_thread.join().expect("imports join"); - for reply in &replies { - assert!(reply.is_ok(), "import reply failed: {reply:?}"); + }); + for (delivery_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "delivery {delivery_ix} reply failed: {reply:?}"); + } + assert_eq!(board(&gate), source_board, "receiver did not converge under duplicated reversed delivery"); + Ok(()) } - let snapshot = snapshot_thread.join().expect("snapshot join"); - assert!(snapshot.is_ok(), "deferred non-import request lost/errored: {snapshot:?}"); - assert_eq!(board(&gate), source_board); - Ok(()) -} -/// The publish pump drains committed local events exactly once. -#[test] -fn pump_publish_drains_exactly_once() -> Result<()> { - let format = FlowFormat::policy_debate(); - let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); - let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); - let _ = pollster::block_on(io.pump_publish())?; - handle - .apply(FlowIntent::CreateSheet { - sheet_id: Uuid::new_v4(), - name: "Published".into(), - sheet_type_id: format.sheet_types[0].id, - }) - .map_err(|error| anyhow::anyhow!("edit rejected: {error}"))?; - let first = pollster::block_on(io.pump_publish())?; - assert!( - first - .iter() - .any(|event| matches!(event, FlowPublishEvent::LocalUpdate { .. })), - "first pump must return the committed LocalUpdate events" - ); - let second = pollster::block_on(io.pump_publish())?; - assert!(second.is_empty(), "second pump must be empty — double-publish hazard"); - Ok(()) -} + /// A non-import request interleaved mid-burst is DEFERRED, not dropped. + #[test] + fn non_import_request_interleaved_with_burst_still_replies() -> Result<()> { + const EDITS: usize = 10; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + let io_imports = io.clone(); + let import_thread = std::thread::spawn(move || { + pollster::block_on(async move { + let mut replies = Vec::new(); + for blob in blobs { + replies.push(io_imports.import_remote_update(blob).await); + } + replies + }) + }); + let io_snapshot = io.clone(); + let snapshot_thread = std::thread::spawn(move || pollster::block_on(io_snapshot.board_snapshot())); + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(hold); + + let replies = import_thread.join().expect("imports join"); + for reply in &replies { + assert!(reply.is_ok(), "import reply failed: {reply:?}"); + } + let snapshot = snapshot_thread.join().expect("snapshot join"); + assert!(snapshot.is_ok(), "deferred non-import request lost/errored: {snapshot:?}"); + assert_eq!(board(&gate), source_board); + Ok(()) + } -/// SaveTo writes a valid v2 .fl0; EncodeBytes yields recovery bytes decoding -/// to the identical board. -#[test] -fn save_and_recovery_bytes_round_trip() -> Result<()> { - let (source_board, _, gate) = drip_pair(1)?; - let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); - let dir = std::env::temp_dir().join(format!("flowstate-flow-io-{}-{}", std::process::id(), Uuid::new_v4())); - std::fs::create_dir_all(&dir)?; - let path = dir.join("saved.fl0"); - pollster::block_on(io.save_to(path.clone()))?; - let snapshot = flowstate_flow::read_fl0(&path)?; - let restored = FlowRuntime::from_snapshot(&snapshot)?; - // The receiver gate has NOT imported the drip blob here — compare against - // its own board, not the source's. - let saved_board = board(&gate); - assert_eq!(restored.board_ref(), &saved_board); - let _ = source_board; - - let recovery = pollster::block_on(io.encode_bytes())?; - let decoded = flowstate_flow::decode_fl0_snapshot(&recovery)?; - let recovered = FlowRuntime::from_snapshot(&decoded)?; - assert_eq!(recovered.board_ref(), &saved_board); - std::fs::remove_dir_all(&dir).ok(); - Ok(()) + /// The publish pump drains committed local events exactly once. + #[test] + fn pump_publish_drains_exactly_once() -> Result<()> { + let format = FlowFormat::policy_debate(); + let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let _ = pollster::block_on(io.pump_publish())?; + handle + .apply(FlowIntent::CreateSheet { + sheet_id: Uuid::new_v4(), + name: "Published".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("edit rejected: {error}"))?; + let first = pollster::block_on(io.pump_publish())?; + assert!( + first + .iter() + .any(|event| matches!(event, FlowPublishEvent::LocalUpdate { .. })), + "first pump must return the committed LocalUpdate events" + ); + let second = pollster::block_on(io.pump_publish())?; + assert!(second.is_empty(), "second pump must be empty — double-publish hazard"); + Ok(()) + } + + /// `SaveTo` writes a valid v2 .fl0; `EncodeBytes` yields recovery bytes decoding + /// to the identical board. + #[test] + fn save_and_recovery_bytes_round_trip() -> Result<()> { + let (source_board, _, gate) = drip_pair(1)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let dir = std::env::temp_dir().join(format!("flowstate-flow-io-{}-{}", std::process::id(), Uuid::new_v4())); + std::fs::create_dir_all(&dir)?; + let path = dir.join("saved.fl0"); + pollster::block_on(io.save_to(path.clone()))?; + let snapshot = flowstate_flow::read_fl0(&path)?; + let restored = FlowRuntime::from_snapshot(&snapshot)?; + // The receiver gate has NOT imported the drip blob here — compare against + // its own board, not the source's. + let saved_board = board(&gate); + assert_eq!(restored.board_ref(), &saved_board); + let _ = source_board; + + let recovery = pollster::block_on(io.encode_bytes())?; + let decoded = flowstate_flow::decode_fl0_snapshot(&recovery)?; + let recovered = FlowRuntime::from_snapshot(&decoded)?; + assert_eq!(recovered.board_ref(), &saved_board); + std::fs::remove_dir_all(&dir).ok(); + Ok(()) + } } diff --git a/crates/flowstate-collab/tests/flow_runtime.rs b/crates/flowstate-collab/tests/flow_runtime.rs index 0b1e1095..4cf0c346 100644 --- a/crates/flowstate-collab/tests/flow_runtime.rs +++ b/crates/flowstate-collab/tests/flow_runtime.rs @@ -1,494 +1,545 @@ -//! FlowRuntime write-path gates (build-order step 3): single-peer intents of +//! `FlowRuntime` write-path gates (build-order step 3): single-peer intents of //! every class, no-op/rejection zero-mutation laws, undo/redo ping-pong per //! class, undo-meta focus restore, and publish-queue convergence. Every //! committed intent also runs the debug board audit (maintained board == //! fresh materialization), so these tests double as derivation-law checks. -use std::sync::Arc; - -use flowstate_collab::flow::{FlowDocHandle, FlowPublishEvent, FlowRuntime}; -use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; -use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; -use flowstate_flow::projection::FlowBoardProjection; -use gpui_flowtext::local_intents::{ - DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, SetMarksIntent, SetParagraphStyleIntent, SplitParagraphIntent, - TextAnchor, -}; -use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; -use uuid::Uuid; - -struct Fixture { - handle: Arc, - format: FlowFormat, - sheet: SheetId, -} - -fn fixture() -> Fixture { - let format = FlowFormat::policy_debate(); - let (handle, _gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("fresh flow runtime")); - let sheet = Uuid::new_v4(); - handle - .apply(FlowIntent::CreateSheet { - sheet_id: sheet, - name: "Case".into(), - sheet_type_id: format.sheet_types[0].id, - }) - .expect("create sheet"); - Fixture { handle, format, sheet } -} +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use flowstate_collab::flow::{FlowDocHandle, FlowPublishEvent, FlowRuntime}; + use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; + use flowstate_flow::projection::FlowBoardProjection; + use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, SetMarksIntent, SetParagraphStyleIntent, SplitParagraphIntent, + TextAnchor, + }; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use uuid::Uuid; + + struct Fixture { + handle: Arc, + format: FlowFormat, + sheet: SheetId, + } -impl Fixture { - fn add_cell(&self, placement: CellPlacement) -> CellId { - let cell = Uuid::new_v4(); - self - .handle - .apply(FlowIntent::AddCell { - sheet_id: self.sheet, - cell_id: cell, - placement, - seed: CellSeed::Empty, + fn fixture() -> Fixture { + let format = FlowFormat::policy_debate(); + let (handle, _gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("fresh flow runtime")); + let sheet = Uuid::new_v4(); + handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Case".into(), + sheet_type_id: format.sheet_types[0].id, }) - .expect("add cell"); - cell + .expect("create sheet"); + Fixture { handle, format, sheet } } - fn board(&self) -> FlowBoardProjection { - self.handle.board_projection().expect("board projection") - } + impl Fixture { + fn add_cell(&self, placement: CellPlacement) -> CellId { + let cell = Uuid::new_v4(); + self + .handle + .apply(FlowIntent::AddCell { + sheet_id: self.sheet, + cell_id: cell, + placement, + seed: CellSeed::Empty, + }) + .expect("add cell"); + cell + } - fn cell_ids(&self) -> Vec { - self.board().sheets[0].cells.iter().map(|cell| cell.id).collect() - } + fn board(&self) -> FlowBoardProjection { + self.handle.board_projection().expect("board projection") + } - fn paragraph_id(&self, cell: CellId, ix: usize) -> gpui_flowtext::ParagraphId { - let projection = self.handle.open_cell(cell).expect("open cell"); - projection.ids.paragraph_ids[ix] - } + fn cell_ids(&self) -> Vec { + self.board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect() + } - fn set_content(&self, cell: CellId, texts: &[(&str, ParagraphStyle)]) { - let paragraphs = texts - .iter() - .map(|(text, style)| InputParagraph { - style: *style, - runs: vec![InputRun { - text: (*text).to_string(), - styles: RunStyles::default(), - }], - }) - .collect(); - self - .handle - .apply(FlowIntent::ReplaceCellContent { - sheet_id: self.sheet, - cell_id: cell, - paragraphs, - }) - .expect("replace cell content"); - } + fn paragraph_id(&self, cell: CellId, ix: usize) -> gpui_flowtext::ParagraphId { + let projection = self.handle.open_cell(cell).expect("open cell"); + projection.ids.paragraph_ids[ix] + } - fn summary(&self, cell: CellId) -> String { - self - .board() - .cell(cell) - .map(|(_, cell)| cell.summary.summary_text.to_string()) - .expect("cell summary") - } -} + fn set_content(&self, cell: CellId, texts: &[(&str, ParagraphStyle)]) { + let paragraphs = texts + .iter() + .map(|(text, style)| InputParagraph { + style: *style, + runs: vec![InputRun { + text: (*text).to_string(), + styles: RunStyles::default(), + }], + }) + .collect(); + self + .handle + .apply(FlowIntent::ReplaceCellContent { + sheet_id: self.sheet, + cell_id: cell, + paragraphs, + }) + .expect("replace cell content"); + } -fn stroke(sheet: SheetId, originator: &str) -> AnnotationStroke { - AnnotationStroke { - id: Uuid::new_v4(), - sheet_id: sheet, - originator: AnnotationOriginator(originator.into()), - points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], - style: StrokeStyle { - color_rgba: 0xff00_00ff, - width: 2.0, - opacity: 0.5, - }, - bbox: BoardRect { - min: BoardPoint { x: 0.0, y: 0.0 }, - max: BoardPoint { x: 1.0, y: 1.0 }, - }, + fn summary(&self, cell: CellId) -> String { + self + .board() + .cell(cell) + .map(|(_, cell)| cell.summary.summary_text.to_string()) + .expect("cell summary") + } } -} -#[test] -fn structural_intents_maintain_the_board_in_place() { - let fx = fixture(); - // Placements: end, top, sibling, first/last response. - let first = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - let top = fx.add_cell(CellPlacement::ColumnTop { column_index: 0 }); - let response = fx.add_cell(CellPlacement::ResponseTo { parent: first }); - let first_response = fx.add_cell(CellPlacement::FirstResponseTo { parent: first }); - let sibling = fx.add_cell(CellPlacement::Sibling { - of: first, - position: RelativePosition::After, - }); - assert_eq!(fx.cell_ids(), vec![top, first, first_response, response, sibling]); - let board = fx.board(); - assert_eq!(board.sheets[0].cell(response).unwrap().parent_id, Some(first)); - assert_eq!(board.sheets[0].cell(first_response).unwrap().parent_id, Some(first)); - assert_eq!(board.sheets[0].cell(sibling).unwrap().parent_id, None); - - // Subtree move: `first` (with its two responses) after `sibling`. - fx.handle - .apply(FlowIntent::MoveCellSubtree { - sheet_id: fx.sheet, - cell_id: first, - drop: FlowDropIntent::AfterSibling(sibling), - }) - .expect("move subtree"); - assert_eq!(fx.cell_ids(), vec![top, sibling, first, first_response, response]); - - // Rename + move sheet + second sheet. - fx.handle - .apply(FlowIntent::RenameSheet { - sheet_id: fx.sheet, - name: "Renamed".into(), - }) - .expect("rename"); - let second_sheet = Uuid::new_v4(); - fx.handle - .apply(FlowIntent::CreateSheet { - sheet_id: second_sheet, - name: "Other".into(), - sheet_type_id: fx.format.sheet_types[1].id, - }) - .expect("second sheet"); - fx.handle - .apply(FlowIntent::MoveSheet { - sheet_id: second_sheet, - target_index: 0, - }) - .expect("move sheet"); - let board = fx.board(); - assert_eq!( - board.sheets.iter().map(|sheet| sheet.id).collect::>(), - vec![second_sheet, fx.sheet] - ); - assert_eq!(board.sheet(fx.sheet).unwrap().name, "Renamed"); - - // Delete a parent: children orphan canonically (no defects on rebuild). - fx.handle - .apply(FlowIntent::DeleteCell { - sheet_id: fx.sheet, - cell_id: first, - }) - .expect("delete cell"); - let board = fx.board(); - assert_eq!(board.sheet(fx.sheet).unwrap().cell(response).unwrap().parent_id, None); - assert_eq!(board.sheet(fx.sheet).unwrap().cell(first_response).unwrap().parent_id, None); - - fx.handle - .apply(FlowIntent::DeleteSheet { sheet_id: second_sheet }) - .expect("delete sheet"); - assert_eq!(fx.board().sheets.len(), 1); -} - -#[test] -fn cell_text_intents_drive_the_cell_stream_and_summary() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - let authority = fx.handle.cell_authority(cell); - let projection = fx.handle.open_cell(cell).expect("open cell"); - assert_eq!(projection.paragraphs.len(), 1); - let paragraph = projection.ids.paragraph_ids[0]; - - use gpui_flowtext::local_intents::LocalWriteAuthority as _; - // Typing. - let outcome = authority - .apply(LocalIntent::InsertText(InsertTextIntent { - at: TextAnchor::new(paragraph, 0), - text: "Warming oceans".into(), - style_override: None, - })) - .expect("insert text"); - let commit = outcome.commit(); - assert!(commit.selection_after.is_some(), "caret snapshot rides the outcome"); - let items = authority.drain_projection_stream().expect("drain"); - assert!(!items.is_empty(), "whole-cell Replace queued for the editor"); - assert_eq!(fx.summary(cell), "Warming oceans", "board summary refreshed"); - - // Split + style + marks. - authority - .apply(LocalIntent::SplitParagraph(SplitParagraphIntent { - at: TextAnchor::new(paragraph, 7), - inherited_style: ParagraphStyle::Normal, - })) - .expect("split"); - let projection = authority.canonical_projection().expect("projection"); - assert_eq!(projection.paragraphs.len(), 2); - let second = projection.ids.paragraph_ids[1]; - authority - .apply(LocalIntent::SetParagraphStyle(SetParagraphStyleIntent { - paragraph: second, - style: flowstate_document::PARAGRAPH_ANALYTIC, - })) - .expect("style"); - authority - .apply(LocalIntent::SetMarks(SetMarksIntent { - start: TextAnchor::new(paragraph, 0), - end: TextAnchor::new(paragraph, 4), - styles: RunStyles { - strikethrough: true, - ..RunStyles::default() + fn stroke(sheet: SheetId, originator: &str) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::new_v4(), + sheet_id: sheet, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 2.0, + opacity: 0.5, }, - })) - .expect("marks"); - let projection = authority.canonical_projection().expect("projection"); - assert!(projection.paragraphs[0].runs.iter().any(|run| run.styles.strikethrough)); - assert_eq!(projection.paragraphs[1].style, flowstate_document::PARAGRAPH_ANALYTIC); - - // Join back + delete a range. - authority - .apply(LocalIntent::JoinParagraphs(JoinParagraphsIntent { - first: paragraph, - second, - })) - .expect("join"); - let projection = authority.canonical_projection().expect("projection"); - assert_eq!(projection.paragraphs.len(), 1); - authority - .apply(LocalIntent::DeleteRange(DeleteRangeIntent { - start: TextAnchor::new(paragraph, 0), - end: TextAnchor::new(paragraph, 8), - })) - .expect("delete range"); - assert_eq!(fx.summary(cell), "oceans"); -} + bbox: BoardRect { + min: BoardPoint { x: 0.0, y: 0.0 }, + max: BoardPoint { x: 1.0, y: 1.0 }, + }, + } + } -#[test] -fn strike_and_editable_and_annotations() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - fx.set_content(cell, &[("argument", ParagraphStyle::Normal)]); - assert!(!fx.board().cell(cell).unwrap().1.summary.uses_summary_projection); - - // Strike on, idempotent no-op, strike off. - fx.handle - .apply(FlowIntent::SetCellStruck { - sheet_id: fx.sheet, - cell_id: cell, - struck: true, - }) - .expect("strike"); - assert!(fx.board().cell(cell).unwrap().1.summary.struck); - let outcome = fx - .handle - .apply(FlowIntent::SetCellStruck { - sheet_id: fx.sheet, - cell_id: cell, - struck: true, - }) - .expect("strike no-op"); - assert!(!outcome.changed, "same-state strike resolves to a no-op"); - fx.handle - .apply(FlowIntent::SetCellStruck { - sheet_id: fx.sheet, - cell_id: cell, - struck: false, - }) - .expect("unstrike"); - assert!(!fx.board().cell(cell).unwrap().1.summary.struck); - - // EnsureCellEditable restyles the first paragraph to the tag style. - fx.handle - .apply(FlowIntent::EnsureCellEditable { - sheet_id: fx.sheet, - cell_id: cell, - }) - .expect("ensure editable"); - assert!(fx.board().cell(cell).unwrap().1.summary.uses_summary_projection); - - // Annotations: add two originators, clear one, delete respects ownership. - let mine = stroke(fx.sheet, "me"); - let theirs = stroke(fx.sheet, "them"); - for annotation in [&mine, &theirs] { + #[test] + fn structural_intents_maintain_the_board_in_place() { + let fx = fixture(); + // Placements: end, top, sibling, first/last response. + let first = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let top = fx.add_cell(CellPlacement::ColumnTop { column_index: 0 }); + let response = fx.add_cell(CellPlacement::ResponseTo { parent: first }); + let first_response = fx.add_cell(CellPlacement::FirstResponseTo { parent: first }); + let sibling = fx.add_cell(CellPlacement::Sibling { + of: first, + position: RelativePosition::After, + }); + assert_eq!(fx.cell_ids(), vec![top, first, first_response, response, sibling]); + let board = fx.board(); + assert_eq!(board.sheets[0].cell(response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(first_response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(sibling).unwrap().parent_id, None); + + // Subtree move: `first` (with its two responses) after `sibling`. fx.handle - .apply(FlowIntent::AddAnnotation { + .apply(FlowIntent::MoveCellSubtree { sheet_id: fx.sheet, - stroke: annotation.clone(), + cell_id: first, + drop: FlowDropIntent::AfterSibling(sibling), }) - .expect("add annotation"); - } - let refused = fx - .handle - .apply(FlowIntent::DeleteAnnotation { - sheet_id: fx.sheet, - stroke_id: theirs.id, - originator: AnnotationOriginator("me".into()), - }) - .expect("delete annotation (foreign)"); - assert!(!refused.changed, "cannot delete another originator's stroke"); - fx.handle - .apply(FlowIntent::ClearAnnotations { - sheet_id: Some(fx.sheet), - originator: AnnotationOriginator("me".into()), - }) - .expect("clear annotations"); - let board = fx.board(); - assert_eq!(board.sheets[0].annotations.len(), 1); - assert_eq!(board.sheets[0].annotations[0].originator, AnnotationOriginator("them".into())); -} + .expect("move subtree"); + assert_eq!(fx.cell_ids(), vec![top, sibling, first, first_response, response]); -#[test] -fn rejections_and_no_ops_leave_the_frontier_untouched() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - let frontier = fx.handle.board_projection().expect("board"); - let before = fx - .handle - .apply(FlowIntent::ClearAnnotations { - sheet_id: None, - originator: AnnotationOriginator("nobody".into()), - }) - .expect("clear nothing"); - assert!(!before.changed); - - // Unknown sheet/cell reject. - assert!( + // Rename + move sheet + second sheet. fx.handle .apply(FlowIntent::RenameSheet { - sheet_id: Uuid::new_v4(), - name: "ghost".into(), + sheet_id: fx.sheet, + name: "Renamed".into(), + }) + .expect("rename"); + let second_sheet = Uuid::new_v4(); + fx.handle + .apply(FlowIntent::CreateSheet { + sheet_id: second_sheet, + name: "Other".into(), + sheet_type_id: fx.format.sheet_types[1].id, }) - .is_err() - ); - assert!( + .expect("second sheet"); + fx.handle + .apply(FlowIntent::MoveSheet { + sheet_id: second_sheet, + target_index: 0, + }) + .expect("move sheet"); + let board = fx.board(); + assert_eq!( + board + .sheets + .iter() + .map(|sheet| sheet.id) + .collect::>(), + vec![second_sheet, fx.sheet] + ); + assert_eq!(board.sheet(fx.sheet).unwrap().name, "Renamed"); + + // Delete a parent: children orphan canonically (no defects on rebuild). fx.handle .apply(FlowIntent::DeleteCell { sheet_id: fx.sheet, - cell_id: Uuid::new_v4(), + cell_id: first, }) - .is_err() - ); - // Self-drop move rejects. - assert!( + .expect("delete cell"); + let board = fx.board(); + assert_eq!( + board + .sheet(fx.sheet) + .unwrap() + .cell(response) + .unwrap() + .parent_id, + None + ); + assert_eq!( + board + .sheet(fx.sheet) + .unwrap() + .cell(first_response) + .unwrap() + .parent_id, + None + ); + fx.handle - .apply(FlowIntent::MoveCellSubtree { + .apply(FlowIntent::DeleteSheet { sheet_id: second_sheet }) + .expect("delete sheet"); + assert_eq!(fx.board().sheets.len(), 1); + } + + #[test] + fn cell_text_intents_drive_the_cell_stream_and_summary() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let authority = fx.handle.cell_authority(cell); + let projection = fx.handle.open_cell(cell).expect("open cell"); + assert_eq!(projection.paragraphs.len(), 1); + let paragraph = projection.ids.paragraph_ids[0]; + + use gpui_flowtext::local_intents::LocalWriteAuthority as _; + // Typing. + let outcome = authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "Warming oceans".into(), + style_override: None, + })) + .expect("insert text"); + let commit = outcome.commit(); + assert!(commit.selection_after.is_some(), "caret snapshot rides the outcome"); + let items = authority.drain_projection_stream().expect("drain"); + assert!(!items.is_empty(), "whole-cell Replace queued for the editor"); + assert_eq!(fx.summary(cell), "Warming oceans", "board summary refreshed"); + + // Split + style + marks. + authority + .apply(LocalIntent::SplitParagraph(SplitParagraphIntent { + at: TextAnchor::new(paragraph, 7), + inherited_style: ParagraphStyle::Normal, + })) + .expect("split"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 2); + let second = projection.ids.paragraph_ids[1]; + authority + .apply(LocalIntent::SetParagraphStyle(SetParagraphStyleIntent { + paragraph: second, + style: flowstate_document::PARAGRAPH_ANALYTIC, + })) + .expect("style"); + authority + .apply(LocalIntent::SetMarks(SetMarksIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 4), + styles: RunStyles { + strikethrough: true, + ..RunStyles::default() + }, + })) + .expect("marks"); + let projection = authority.canonical_projection().expect("projection"); + assert!( + projection.paragraphs[0] + .runs + .iter() + .any(|run| run.styles.strikethrough) + ); + assert_eq!(projection.paragraphs[1].style, flowstate_document::PARAGRAPH_ANALYTIC); + + // Join back + delete a range. + authority + .apply(LocalIntent::JoinParagraphs(JoinParagraphsIntent { first: paragraph, second })) + .expect("join"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 1); + authority + .apply(LocalIntent::DeleteRange(DeleteRangeIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 8), + })) + .expect("delete range"); + assert_eq!(fx.summary(cell), "oceans"); + } + + #[test] + fn strike_and_editable_and_annotations() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("argument", ParagraphStyle::Normal)]); + assert!( + !fx + .board() + .cell(cell) + .unwrap() + .1 + .summary + .uses_summary_projection + ); + + // Strike on, idempotent no-op, strike off. + fx.handle + .apply(FlowIntent::SetCellStruck { sheet_id: fx.sheet, cell_id: cell, - drop: FlowDropIntent::LastChildOf(cell), + struck: true, }) - .is_err() - ); - assert_eq!(fx.handle.board_projection().expect("board"), frontier, "no mutation escaped"); -} + .expect("strike"); + assert!(fx.board().cell(cell).unwrap().1.summary.struck); + let outcome = fx + .handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike no-op"); + assert!(!outcome.changed, "same-state strike resolves to a no-op"); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: false, + }) + .expect("unstrike"); + assert!(!fx.board().cell(cell).unwrap().1.summary.struck); -#[test] -fn undo_redo_ping_pong_across_intent_classes() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - fx.set_content(cell, &[("tag line", flowstate_document::PARAGRAPH_TAG)]); - let other = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - - // A representative mutation per class, applied in sequence. - let steps: Vec = vec![ - FlowIntent::RenameSheet { - sheet_id: fx.sheet, - name: "Undoable".into(), - }, - FlowIntent::MoveCellSubtree { - sheet_id: fx.sheet, - cell_id: other, - drop: FlowDropIntent::BeforeSibling(cell), - }, - FlowIntent::SetCellStruck { - sheet_id: fx.sheet, - cell_id: cell, - struck: true, - }, - FlowIntent::AddAnnotation { - sheet_id: fx.sheet, - stroke: stroke(fx.sheet, "me"), - }, - FlowIntent::DeleteCell { - sheet_id: fx.sheet, - cell_id: other, - }, - FlowIntent::CellText { - cell_id: cell, - intent: LocalIntent::InsertText(InsertTextIntent { - at: TextAnchor::new(fx.paragraph_id(cell, 0), 0), - text: "typed ".into(), - style_override: None, - }), - }, - ]; - - let mut boards = vec![fx.board()]; - for step in steps { - fx.handle.apply(step).expect("apply step"); - boards.push(fx.board()); + // EnsureCellEditable restyles the first paragraph to the tag style. + fx.handle + .apply(FlowIntent::EnsureCellEditable { + sheet_id: fx.sheet, + cell_id: cell, + }) + .expect("ensure editable"); + assert!( + fx.board() + .cell(cell) + .unwrap() + .1 + .summary + .uses_summary_projection + ); + + // Annotations: add two originators, clear one, delete respects ownership. + let mine = stroke(fx.sheet, "me"); + let theirs = stroke(fx.sheet, "them"); + for annotation in [&mine, &theirs] { + fx.handle + .apply(FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: annotation.clone(), + }) + .expect("add annotation"); + } + let refused = fx + .handle + .apply(FlowIntent::DeleteAnnotation { + sheet_id: fx.sheet, + stroke_id: theirs.id, + originator: AnnotationOriginator("me".into()), + }) + .expect("delete annotation (foreign)"); + assert!(!refused.changed, "cannot delete another originator's stroke"); + fx.handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: Some(fx.sheet), + originator: AnnotationOriginator("me".into()), + }) + .expect("clear annotations"); + let board = fx.board(); + assert_eq!(board.sheets[0].annotations.len(), 1); + assert_eq!(board.sheets[0].annotations[0].originator, AnnotationOriginator("them".into())); } - // Undo all the way down the stack, checking each intermediate board. - for expected in boards.iter().rev().skip(1) { - let outcome = fx.handle.undo().expect("undo"); - assert!(outcome.applied, "undo stack must not run dry early"); - assert_eq!(&fx.board(), expected, "undo restored the exact prior board"); + #[test] + fn rejections_and_no_ops_leave_the_frontier_untouched() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let frontier = fx.handle.board_projection().expect("board"); + let before = fx + .handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: None, + originator: AnnotationOriginator("nobody".into()), + }) + .expect("clear nothing"); + assert!(!before.changed); + + // Unknown sheet/cell reject. + assert!( + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: Uuid::new_v4(), + name: "ghost".into(), + }) + .is_err() + ); + assert!( + fx.handle + .apply(FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: Uuid::new_v4(), + }) + .is_err() + ); + // Self-drop move rejects. + assert!( + fx.handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: cell, + drop: FlowDropIntent::LastChildOf(cell), + }) + .is_err() + ); + assert_eq!(fx.handle.board_projection().expect("board"), frontier, "no mutation escaped"); } - // Redo all the way back up. - for expected in boards.iter().skip(1) { - let outcome = fx.handle.redo().expect("redo"); - assert!(outcome.applied, "redo stack must not run dry early"); - assert_eq!(&fx.board(), expected, "redo restored the exact next board"); + + #[test] + fn undo_redo_ping_pong_across_intent_classes() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("tag line", flowstate_document::PARAGRAPH_TAG)]); + let other = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + + // A representative mutation per class, applied in sequence. + let steps: Vec = vec![ + FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Undoable".into(), + }, + FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: other, + drop: FlowDropIntent::BeforeSibling(cell), + }, + FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }, + FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: stroke(fx.sheet, "me"), + }, + FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: other, + }, + FlowIntent::CellText { + cell_id: cell, + intent: LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(fx.paragraph_id(cell, 0), 0), + text: "typed ".into(), + style_override: None, + }), + }, + ]; + + let mut boards = vec![fx.board()]; + for step in steps { + fx.handle.apply(step).expect("apply step"); + boards.push(fx.board()); + } + + // Undo all the way down the stack, checking each intermediate board. + for expected in boards.iter().rev().skip(1) { + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied, "undo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "undo restored the exact prior board"); + } + // Redo all the way back up. + for expected in boards.iter().skip(1) { + let outcome = fx.handle.redo().expect("redo"); + assert!(outcome.applied, "redo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "redo restored the exact next board"); + } } -} -#[test] -fn undo_meta_restores_board_focus_context() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - fx.handle.set_undo_context(flowstate_collab::flow::runtime::FlowUndoMeta { - active_sheet: Some(fx.sheet), - focused_cell: Some(cell), - head_cursor: None, - anchor_cursor: None, - }); - fx.handle - .apply(FlowIntent::SetCellStruck { - sheet_id: fx.sheet, - cell_id: cell, - struck: true, - }) - .expect("strike under context"); - fx.set_content(cell, &[("x", ParagraphStyle::Normal)]); // later commit with same context - let outcome = fx.handle.undo().expect("undo"); - assert!(outcome.applied); - let meta = outcome.meta.expect("undo meta restored"); - assert_eq!(meta.active_sheet, Some(fx.sheet)); - assert_eq!(meta.focused_cell, Some(cell)); -} + #[test] + fn undo_meta_restores_board_focus_context() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.handle + .set_undo_context(flowstate_collab::flow::runtime::FlowUndoMeta { + active_sheet: Some(fx.sheet), + focused_cell: Some(cell), + head_cursor: None, + anchor_cursor: None, + }); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike under context"); + fx.set_content(cell, &[("x", ParagraphStyle::Normal)]); // later commit with same context + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied); + let meta = outcome.meta.expect("undo meta restored"); + assert_eq!(meta.active_sheet, Some(fx.sheet)); + assert_eq!(meta.focused_cell, Some(cell)); + } + + #[test] + fn publish_queue_converges_a_second_runtime() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("shared", flowstate_document::PARAGRAPH_TAG)]); -#[test] -fn publish_queue_converges_a_second_runtime() { - let fx = fixture(); - let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); - fx.set_content(cell, &[("shared", flowstate_document::PARAGRAPH_TAG)]); - - // Bootstrap peer B from a snapshot, then apply more local edits on A. - let snapshot = fx - .handle - .with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); - let (peer_b, _gate_b) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer B")); - fx.handle - .apply(FlowIntent::RenameSheet { - sheet_id: fx.sheet, - name: "Post-snapshot".into(), - }) - .expect("rename"); - - // Drain A's publish queue and import the update bytes into B. - let events = fx.handle.with_test_runtime(FlowRuntime::take_pending_publish); - let mut imported = false; - for event in events { - if let FlowPublishEvent::LocalUpdate { bytes, .. } = event { - peer_b.with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()]).expect("import")); - imported = true; + // Bootstrap peer B from a snapshot, then apply more local edits on A. + let snapshot = fx + .handle + .with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let (peer_b, _gate_b) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer B")); + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Post-snapshot".into(), + }) + .expect("rename"); + + // Drain A's publish queue and import the update bytes into B. + let events = fx + .handle + .with_test_runtime(FlowRuntime::take_pending_publish); + let mut imported = false; + for event in events { + if let FlowPublishEvent::LocalUpdate { bytes, .. } = event { + peer_b.with_test_runtime(|runtime| { + runtime + .import_remote_updates(&[bytes.as_slice()]) + .expect("import"); + }); + imported = true; + } } + assert!(imported, "local commits queued publishable updates"); + assert_eq!(peer_b.board_projection().expect("board"), fx.board(), "peers converged"); } - assert!(imported, "local commits queued publishable updates"); - assert_eq!(peer_b.board_projection().expect("board"), fx.board(), "peers converged"); } diff --git a/crates/flowstate-collab/tests/swarm_loopback.rs b/crates/flowstate-collab/tests/swarm_loopback.rs index 254ae36e..de6d895e 100644 --- a/crates/flowstate-collab/tests/swarm_loopback.rs +++ b/crates/flowstate-collab/tests/swarm_loopback.rs @@ -130,7 +130,13 @@ async fn swarm_loopback_net_subset() -> Result<()> { // owner proxy: C can authenticate directly to B and bootstrap from B even // if A is unavailable. install_snapshot_handler(&b, session, b"snapshot-from-b".to_vec()).await; - let b_invite = SessionTicket::new(session, vec![b_addr.clone()], "Shared brief".into(), admission.clone(), flowstate_collab::ticket::DocumentKind::RichText); + let b_invite = SessionTicket::new( + session, + vec![b_addr.clone()], + "Shared brief".into(), + admission.clone(), + flowstate_collab::ticket::DocumentKind::RichText, + ); let b_invite = SessionTicket::decode_text(&b_invite.encode_text())?; let snapshot = direct::pull_with_endpoint( &c.endpoint, @@ -257,13 +263,7 @@ async fn flow_direct_snapshot_and_updates_pull() -> Result<()> { .await; // B pulls the snapshot and reconstructs the identical board. - let snapshot = direct::pull_with_endpoint( - &b.endpoint, - DirectRequest::Snapshot { session }, - vec![a_addr.id], - Duration::from_secs(5), - ) - .await?; + let snapshot = direct::pull_with_endpoint(&b.endpoint, DirectRequest::Snapshot { session }, vec![a_addr.id], Duration::from_secs(5)).await?; let joined = FlowRuntime::from_snapshot(&snapshot).expect("joiner runtime"); assert_eq!(joined.board_ref(), &expected_board); @@ -288,7 +288,10 @@ async fn flow_direct_snapshot_and_updates_pull() -> Result<()> { let mut joined = joined; joined.import_remote_updates(&[updates.as_slice()])?; assert_eq!( - joined.board_ref().sheet(sheet).map(|sheet| sheet.name.as_str()), + joined + .board_ref() + .sheet(sheet) + .map(|sheet| sheet.name.as_str()), Some("Renamed after snapshot") ); a.router.shutdown().await.ok(); diff --git a/crates/flowstate-document/src/lib.rs b/crates/flowstate-document/src/lib.rs index 553b8d27..cf0f7b5c 100644 --- a/crates/flowstate-document/src/lib.rs +++ b/crates/flowstate-document/src/lib.rs @@ -27,9 +27,8 @@ pub use loro_schema::{ SectionPageAttrs, SectionPageNumbering, SectionPageSize, TABLE_CELLS_BY_ID, TABLE_COLUMN_ORDER, TABLE_COLUMNS_BY_ID, TABLE_KEY, TABLE_ROW_ORDER, TABLE_ROWS_BY_ID, USERS_BY_ID, cell_flow_loro_id, cell_loro_id, cell_loro_id_for, column_loro_id, document_id, document_schema_version, ensure_flow, ensure_section, ensure_sentinel, fork_document_lineage, init_loro_document, new_loro_document, - parse_cell_loro_id, - parse_column_loro_id, parse_row_loro_id, read_section_page_attrs, record_revision, register_replica, register_user, row_loro_id, - set_section_page_attrs, touch_document_metadata, + parse_cell_loro_id, parse_column_loro_id, parse_row_loro_id, read_section_page_attrs, record_revision, register_replica, register_user, + row_loro_id, set_section_page_attrs, touch_document_metadata, }; pub use package::{ AssetChunk, ChunkRef, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD, DocumentPackage, DocumentPackageManifest, IntegrityIndexEntry, diff --git a/crates/flowstate-document/src/loro_projection.rs b/crates/flowstate-document/src/loro_projection.rs index 16d79c4e..0146e80f 100644 --- a/crates/flowstate-document/src/loro_projection.rs +++ b/crates/flowstate-document/src/loro_projection.rs @@ -1477,11 +1477,11 @@ pub fn paragraph_ids_by_boundary_in(doc: &LoroDoc, paragraphs: &LoroMap, text: & // dead-anchor / id-less-cursor fallbacks are all preserved bit-identically. let container = text.id(); // §perf-heaven T4: consume the sorted keys by value (no per-key clone). - let sorted_keys = map_keys(¶graphs); + let sorted_keys = map_keys(paragraphs); let mut cands: Vec<(String, Option, Option)> = Vec::with_capacity(sorted_keys.len()); let mut ids: Vec = Vec::new(); for key in sorted_keys { - let Some(record) = child_map(¶graphs, &key).ok().flatten() else { + let Some(record) = child_map(paragraphs, &key).ok().flatten() else { continue; }; let decode = |field: &str| { @@ -2433,18 +2433,28 @@ mod tests { .ensure_mergeable_text(crate::loro_schema::FLOW_TEXT_KEY) .expect("flow text"); crate::loro_schema::ensure_sentinel(&text).expect("sentinel"); - let paragraphs = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID).expect("paragraph registry"); + let paragraphs = flow + .ensure_mergeable_map(PARAGRAPHS_BY_ID) + .expect("paragraph registry"); standalone_paragraph_record(¶graphs, &text, ROOT_FIRST_PARAGRAPH_ID, flow_id, 0); (flow, text, paragraphs) } fn standalone_paragraph_record(paragraphs: &LoroMap, text: &LoroText, key: &str, flow_id: &str, boundary: usize) { - let record = paragraphs.ensure_mergeable_map(key).expect("paragraph record"); + let record = paragraphs + .ensure_mergeable_map(key) + .expect("paragraph record"); record.insert("id", key).expect("record id"); record.insert("flow_id", flow_id).expect("record flow id"); - let cursor = text.get_cursor(boundary, Side::Left).expect("boundary cursor"); - record.insert("boundary_cursor", cursor.encode()).expect("boundary cursor bytes"); - record.insert("start_cursor", cursor.encode()).expect("start cursor bytes"); + let cursor = text + .get_cursor(boundary, Side::Left) + .expect("boundary cursor"); + record + .insert("boundary_cursor", cursor.encode()) + .expect("boundary cursor bytes"); + record + .insert("start_cursor", cursor.encode()) + .expect("start cursor bytes"); } /// The .fl0 cell round trip: a standalone flow with styled multi-paragraph @@ -2459,14 +2469,18 @@ mod tests { let (flow, text, paragraphs) = seed_standalone_flow(®istry, flow_id); // "\n" (sentinel, Normal) + "Hello wörld" + "\n" (Custom(3) tag row) + "café tail". - text.insert(1, "Hello wörld").expect("insert first paragraph"); + text + .insert(1, "Hello wörld") + .expect("insert first paragraph"); let boundary = text.len_unicode(); text.insert(boundary, "\n").expect("insert boundary"); text .mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, 4_i64) .expect("mark boundary style"); // slot value 4 → Custom(3) standalone_paragraph_record(¶graphs, &text, "paragraph.row2", flow_id, boundary); - text.insert(boundary + 1, "café tail").expect("insert second paragraph"); + text + .insert(boundary + 1, "café tail") + .expect("insert second paragraph"); // Strikethrough over "wörld" (unicode 7..12) — the strike_cell shape. text .mark(7..12, MARK_STRIKETHROUGH, true) @@ -2481,7 +2495,11 @@ mod tests { }; assert_eq!(first.style, gpui_flowtext::ParagraphStyle::Normal); assert_eq!( - first.runs.iter().map(|run| run.text.as_str()).collect::(), + first + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), "Hello wörld" ); assert!( @@ -2495,7 +2513,14 @@ mod tests { panic!("second row is a paragraph"); }; assert_eq!(second.style, gpui_flowtext::ParagraphStyle::Custom(3)); - assert_eq!(second.runs.iter().map(|run| run.text.as_str()).collect::(), "café tail"); + assert_eq!( + second + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + "café tail" + ); // Durable identities: the seeded first record + the explicit second record, // and block ids derived 1:1 from them. assert_eq!( diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index cdc5345b..f88702d7 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -11,9 +11,15 @@ workspace = true anyhow = { workspace = true } flowstate-document.workspace = true gpui-flowtext = { workspace = true } +hotpath.workspace = true loro = "1.13.1" postcard = "1.1.3" rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } uuid = { workspace = true, features = ["serde", "v4"] } zstd.workspace = true + +[features] +hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "gpui-flowtext/hotpath"] +hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] +hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] diff --git a/crates/flowstate-flow/examples/dump_flow.rs b/crates/flowstate-flow/examples/dump_flow.rs index d49e20d1..7064bb6b 100644 --- a/crates/flowstate-flow/examples/dump_flow.rs +++ b/crates/flowstate-flow/examples/dump_flow.rs @@ -1,42 +1,59 @@ -//! Dumps a `.fl0` flow's topology and full cell text (including any notes typed into cells). +//! Dumps a `.fl0` (v2, Loro-first) flow's topology and full cell text +//! (including any notes typed into cells). //! //! Run: `cargo run -p flowstate-flow --example dump_flow -- ` -use flowstate_flow::load_flow_document; +use flowstate_flow::{loro_projection::materialize_board, loro_schema, read_fl0}; +use loro::LoroDoc; fn main() { let path = std::env::args() .nth(1) .unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); - let document = load_flow_document(&path).expect("load flow"); - let projection = document.projection(); + let snapshot = read_fl0(&path).expect("read .fl0"); + let doc = LoroDoc::new(); + doc + .import_with(&snapshot, "remote") + .expect("import snapshot"); + let (board, defects) = materialize_board(&doc).expect("materialize board"); + if !defects.is_empty() { + println!("normalization defects: {defects:?}"); + } - for sheet in &projection.sheets { - let definition = projection.format.sheet_type(sheet.sheet_type_id); + for sheet in &board.sheets { + let definition = board.format.sheet_type(sheet.sheet_type_id); let column_name = |id| { - definition.and_then(|d: &flowstate_flow::SheetTypeDefinition| { - d.columns + definition.and_then(|definition: &flowstate_flow::SheetTypeDefinition| { + definition + .columns .iter() - .find(|c| c.id == id) - .map(|c| c.label.clone()) + .find(|column| column.id == id) + .map(|column| column.label.clone()) }) }; let label = |id| { sheet .cells .iter() - .find(|c| c.id == id) - .and_then(|c| c.summary_text().ok()) - .map(|t| t.lines().next().unwrap_or_default().trim().to_string()) + .find(|cell| cell.id == id) + .map(|cell| { + cell + .summary + .summary_text + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string() + }) .unwrap_or_default() }; println!("\n===== SHEET: {} ({} cells) =====", sheet.name, sheet.cells.len()); for (index, cell) in sheet.cells.iter().enumerate() { let col = column_name(cell.column_id).unwrap_or_else(|| "?".into()); let parent = cell.parent_id.map(label).unwrap_or_else(|| "—".into()); - let text = cell - .document() - .map(|d| d.text.to_string()) + let text = loro_schema::cell_text(&doc, cell.id) + .map(|text| text.to_string()) .unwrap_or_default(); println!("[{index:2}] col={col:<6} parent={parent}"); for line in text.lines() { diff --git a/crates/flowstate-flow/examples/ergonomics_fixture.rs b/crates/flowstate-flow/examples/ergonomics_fixture.rs index 170f5ae4..8fba4a75 100644 --- a/crates/flowstate-flow/examples/ergonomics_fixture.rs +++ b/crates/flowstate-flow/examples/ergonomics_fixture.rs @@ -1,17 +1,23 @@ -//! Generates a labeled `.fl0` flow document for drag-and-drop ergonomics testing. +//! Generates a labeled `.fl0` (v2, Loro-first) flow document for drag-and-drop +//! ergonomics testing. //! //! Run: `cargo run -p flowstate-flow --example ergonomics_fixture -- [out.fl0]` //! //! Every cell's tag text is a unique, stable label (A1, B1, …) that shows on the board and in the //! telemetry log, so the instruction guide and `FLOWSTATE_DRAG_LOG` output line up by name. The //! topology deliberately spans the move types the guide exercises: sibling runs, deep parent→child -//! chains, orphans mid-column, and cells of varied height. +//! chains, orphans mid-column, and cells of varied height. Cells are written in canonical DFS +//! order so the persisted order lists match the write-path invariant. use flowstate_document::{ - DocumentProjection, InputParagraph, InputRun, PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, ParagraphStyle, RunStyles, SEMANTIC_CITE, - document_from_input, flowstate_document_theme, + InputParagraph, InputRun, PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, ParagraphStyle, RunStyles, SEMANTIC_CITE, }; -use flowstate_flow::{CellId, FlowDocument, SheetId, save_flow_document}; +use flowstate_flow::{ + CellId, FlowFormat, SheetId, encode_fl0_snapshot, + loro_projection::materialize_board, + loro_schema::{cell_flow_from_paragraphs, init_flow_document, write_cell, write_sheet}, +}; +use loro::LoroDoc; #[derive(Clone, Copy)] enum Tier { @@ -38,7 +44,7 @@ fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { InputParagraph { style, runs } } -fn content(label: &str, tier: Tier) -> DocumentProjection { +fn content(label: &str, tier: Tier) -> Vec { let mut paragraphs = vec![paragraph(PARAGRAPH_TAG, vec![run(label)])]; match tier { Tier::Short => {}, @@ -64,69 +70,90 @@ fn content(label: &str, tier: Tier) -> DocumentProjection { ], )), } - document_from_input(flowstate_document_theme(), paragraphs) + paragraphs +} + +struct Fixture { + doc: LoroDoc, + format: FlowFormat, + sheet_type_index: usize, + next_order: usize, } -fn add(document: &mut FlowDocument, sheet: SheetId, column: usize, parent: Option, label: &str, tier: Tier) -> CellId { - let id = document - .add_plain_cell(sheet, column, parent, None) - .expect("add cell"); - document - .replace_cell_document(sheet, id, &content(label, tier)) - .expect("set cell content"); - id +impl Fixture { + fn sheet(&mut self, name: &str, sheet_type_index: usize, order_index: usize) -> SheetId { + let sheet = SheetId::new_v4(); + write_sheet(&self.doc, sheet, name, self.format.sheet_types[sheet_type_index].id, order_index).expect("write sheet"); + self.sheet_type_index = sheet_type_index; + self.next_order = 0; + sheet + } + + fn add(&mut self, sheet: SheetId, column: usize, parent: Option, label: &str, tier: Tier) -> CellId { + let id = CellId::new_v4(); + let column_id = self.format.sheet_types[self.sheet_type_index].columns[column].id; + let cell = write_cell(&self.doc, sheet, id, column_id, parent, self.next_order).expect("write cell"); + self.next_order += 1; + cell_flow_from_paragraphs(&cell, &content(label, tier)).expect("seed cell content"); + id + } } fn main() { let out = std::env::args() .nth(1) .unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); - let mut document = FlowDocument::new(); - let affirmative = document.projection().format.sheet_types[0].id; // 1AC 1NC 2AC Block 1AR 2NR 2AR - let negative = document.projection().format.sheet_types[1].id; // 1NC 2AC Block 1AR 2NR 2AR - - // ---- Sheet 1: "Ergonomics" (Affirmative, 7 columns) ---- - let sheet = document - .create_sheet("Ergonomics", affirmative) - .expect("create sheet"); - - // A1 with two responses B1/B2, and a deep chain B1 -> C1 -> D1 -> E1 -> F1. - let a1 = add(&mut document, sheet, 0, None, "A1", Tier::Medium); - let b1 = add(&mut document, sheet, 1, Some(a1), "B1", Tier::Card); - add(&mut document, sheet, 1, Some(a1), "B2", Tier::Short); - let c1 = add(&mut document, sheet, 2, Some(b1), "C1", Tier::Medium); - let d1 = add(&mut document, sheet, 3, Some(c1), "D1", Tier::Short); - let e1 = add(&mut document, sheet, 4, Some(d1), "E1", Tier::Short); - add(&mut document, sheet, 5, Some(e1), "F1", Tier::Medium); + let doc = LoroDoc::new(); + let format = FlowFormat::policy_debate(); + init_flow_document(&doc, &format).expect("init flow document"); + let mut fixture = Fixture { + doc, + format: format.clone(), + sheet_type_index: 0, + next_order: 0, + }; + + // ---- Sheet 1: "Ergonomics" (Affirmative) — written in canonical DFS order: + // each root followed by its whole subtree, orphans as trailing roots. + let sheet = fixture.sheet("Ergonomics", 0, 0); + + // A1 with a deep chain B1 -> C1 -> D1 -> E1 -> F1, then sibling B2. + let a1 = fixture.add(sheet, 0, None, "A1", Tier::Medium); + let b1 = fixture.add(sheet, 1, Some(a1), "B1", Tier::Card); + let c1 = fixture.add(sheet, 2, Some(b1), "C1", Tier::Medium); + let d1 = fixture.add(sheet, 3, Some(c1), "D1", Tier::Short); + let e1 = fixture.add(sheet, 4, Some(d1), "E1", Tier::Short); + fixture.add(sheet, 5, Some(e1), "F1", Tier::Medium); + fixture.add(sheet, 1, Some(a1), "B2", Tier::Short); // A2 with a response B3 that has two children C2/C3. - let a2 = add(&mut document, sheet, 0, None, "A2", Tier::Tall); - let b3 = add(&mut document, sheet, 1, Some(a2), "B3", Tier::Medium); - add(&mut document, sheet, 2, Some(b3), "C2", Tier::Short); - add(&mut document, sheet, 2, Some(b3), "C3", Tier::Tall); + let a2 = fixture.add(sheet, 0, None, "A2", Tier::Tall); + let b3 = fixture.add(sheet, 1, Some(a2), "B3", Tier::Medium); + fixture.add(sheet, 2, Some(b3), "C2", Tier::Short); + fixture.add(sheet, 2, Some(b3), "C3", Tier::Tall); // A3 leaf root, plus its response B5. - let a3 = add(&mut document, sheet, 0, None, "A3", Tier::Short); - add(&mut document, sheet, 1, Some(a3), "B5", Tier::Short); + let a3 = fixture.add(sheet, 0, None, "A3", Tier::Short); + fixture.add(sheet, 1, Some(a3), "B5", Tier::Short); // Orphans (no parent) sitting in interior columns. - add(&mut document, sheet, 1, None, "B4", Tier::Short); - add(&mut document, sheet, 2, None, "C4", Tier::Short); - - // ---- Sheet 2: "Scratch" (Negative, 6 columns) ---- - let scratch = document - .create_sheet("Scratch", negative) - .expect("create sheet"); - let s1 = add(&mut document, scratch, 0, None, "S1", Tier::Short); - add(&mut document, scratch, 1, Some(s1), "S2", Tier::Short); - add(&mut document, scratch, 0, None, "S3", Tier::Medium); - - save_flow_document(&out, &document).expect("save fixture"); - let projection = document.projection(); - let cells: usize = projection - .sheets - .iter() - .map(|sheet| sheet.cells.len()) - .sum(); - println!("wrote {out} — {} sheets, {cells} cells", projection.sheets.len()); + fixture.add(sheet, 1, None, "B4", Tier::Short); + fixture.add(sheet, 2, None, "C4", Tier::Short); + + // ---- Sheet 2: "Scratch" (Negative) ---- + let scratch = fixture.sheet("Scratch", 1, 1); + let s1 = fixture.add(scratch, 0, None, "S1", Tier::Short); + fixture.add(scratch, 1, Some(s1), "S2", Tier::Short); + fixture.add(scratch, 0, None, "S3", Tier::Medium); + + fixture.doc.commit(); + let snapshot = fixture + .doc + .export(loro::ExportMode::Snapshot) + .expect("export snapshot"); + std::fs::write(&out, encode_fl0_snapshot(&snapshot)).expect("save fixture"); + let (board, defects) = materialize_board(&fixture.doc).expect("materialize board"); + assert!(defects.is_empty(), "fixture must materialize defect-free: {defects:?}"); + let cells: usize = board.sheets.iter().map(|sheet| sheet.cells.len()).sum(); + println!("wrote {out} — {} sheets, {cells} cells", board.sheets.len()); } diff --git a/crates/flowstate-flow/src/loro_projection.rs b/crates/flowstate-flow/src/loro_projection.rs index 51333b01..d8b24cb8 100644 --- a/crates/flowstate-flow/src/loro_projection.rs +++ b/crates/flowstate-flow/src/loro_projection.rs @@ -19,9 +19,7 @@ use std::sync::Arc; -use flowstate_document::{ - PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, RegionRows, SEMANTIC_CITE, materialize_single_flow, -}; +use flowstate_document::{PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, RegionRows, SEMANTIC_CITE, materialize_single_flow}; use gpui_flowtext::{DocumentProjection, DocumentTheme, InputBlock, document_from_input_blocks}; use loro::{LoroDoc, LoroMap}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -29,8 +27,8 @@ use uuid::Uuid; use crate::format::{CellId, SheetId}; use crate::loro_schema::{ - cell_flow_label, cell_flow_map, cell_order_ids, cells_by_id, child_map, map_string, map_uuid, read_annotations, read_format, - sheet_order_ids, sheets_by_id, + cell_flow_label, cell_flow_map, cell_order_ids, cells_by_id, child_map, map_string, map_uuid, read_annotations, read_format, sheet_order_ids, + sheets_by_id, }; use crate::projection::{Cell, CellSummary, FlowBoardProjection, Sheet}; @@ -66,7 +64,10 @@ pub enum FlowDefect { /// Cell record without a seeded flow container → empty summary. CellMissingFlow { cell: CellId }, /// A defect reported by the shared rich-text materializer for one cell. - CellFlow { cell: CellId, defect: flowstate_document::ProjectionDefect }, + CellFlow { + cell: CellId, + defect: flowstate_document::ProjectionDefect, + }, } impl FlowDefect { @@ -92,6 +93,7 @@ impl FlowDefect { /// Materialize the whole board. Deterministic: byte-equal boards on every peer /// sharing the same canonical state, regardless of local op history. +#[hotpath::measure] pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, Vec)> { let format = read_format(doc)?; let mut defects = Vec::new(); @@ -212,11 +214,11 @@ pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, let mut linear: Vec = Vec::with_capacity(raw_cells.len()); let mut seen: FxHashSet = FxHashSet::default(); for entry in cell_order_ids(&sheet_map) { - let Some(id) = Uuid::parse_str(&entry).ok().filter(|id| by_id.contains_key(id)) else { - defects.push(FlowDefect::CellOrderMissingRecord { - sheet: sheet_id, - key: entry, - }); + let Some(id) = Uuid::parse_str(&entry) + .ok() + .filter(|id| by_id.contains_key(id)) + else { + defects.push(FlowDefect::CellOrderMissingRecord { sheet: sheet_id, key: entry }); continue; }; if seen.insert(id) { @@ -226,10 +228,9 @@ pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, // raw_cells is uuid-key-sorted already, so the append order is // deterministic. for cell in &raw_cells { - if !seen.contains(&cell.id) { + if seen.insert(cell.id) { defects.push(FlowDefect::CellRecordMissingOrder { cell: cell.id }); linear.push(cell.id); - seen.insert(cell.id); } } @@ -244,12 +245,12 @@ pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, } // Step 3 (dangling parent → orphan). - for ix in 0..raw_cells.len() { - if let Some(parent) = raw_cells[ix].parent_id + for cell in &mut raw_cells { + if let Some(parent) = cell.parent_id && !by_id.contains_key(&parent) { - defects.push(FlowDefect::DanglingParent { cell: raw_cells[ix].id }); - raw_cells[ix].parent_id = None; + defects.push(FlowDefect::DanglingParent { cell: cell.id }); + cell.parent_id = None; } } @@ -305,16 +306,16 @@ pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, .iter() .filter_map(|cell| level_of(cell.column_id).map(|level| (cell.id, level))) .collect(); - for ix in 0..raw_cells.len() { - let Some(parent) = raw_cells[ix].parent_id else { + for cell in &mut raw_cells { + let Some(parent) = cell.parent_id else { continue; }; - let child_level = levels.get(&raw_cells[ix].id).copied(); + let child_level = levels.get(&cell.id).copied(); let parent_level = levels.get(&parent).copied(); let adjacent = matches!((child_level, parent_level), (Some(child), Some(parent)) if child == parent + 1); if !adjacent { - defects.push(FlowDefect::ColumnAdjacency { cell: raw_cells[ix].id }); - raw_cells[ix].parent_id = None; + defects.push(FlowDefect::ColumnAdjacency { cell: cell.id }); + cell.parent_id = None; } } @@ -397,6 +398,7 @@ pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, /// Materialize ONE cell's full rich-text projection (editor attach). The board /// carries only summaries; this is the on-demand deep materialization. +#[hotpath::measure] pub fn materialize_cell_projection(doc: &LoroDoc, cell_id: CellId, theme: DocumentTheme) -> anyhow::Result { let rows = materialize_cell_rows(doc, cell_id)?; Ok(document_from_rows(doc, rows, theme)) @@ -404,6 +406,7 @@ pub fn materialize_cell_projection(doc: &LoroDoc, cell_id: CellId, theme: Docume /// The raw materialized rows for one cell (shared by the projection above and /// the runtime's summary refresh). +#[hotpath::measure] pub fn materialize_cell_rows(doc: &LoroDoc, cell_id: CellId) -> anyhow::Result { let cell = crate::loro_schema::cell_map(doc, cell_id).ok_or_else(|| anyhow::anyhow!("unknown cell {cell_id}"))?; let flow = cell_flow_map(&cell).ok_or_else(|| anyhow::anyhow!("cell {cell_id} has no flow"))?; diff --git a/crates/flowstate-flow/src/loro_schema.rs b/crates/flowstate-flow/src/loro_schema.rs index 069c5262..14203c25 100644 --- a/crates/flowstate-flow/src/loro_schema.rs +++ b/crates/flowstate-flow/src/loro_schema.rs @@ -15,7 +15,7 @@ //! (write-once/delete-only — already proven convergent) //! ``` //! -//! Ordering is MovableList (`mov` writes only the order list, never a cell map +//! Ordering is `MovableList` (`mov` writes only the order list, never a cell map //! or text container, so a reorder can never clobber a concurrent text edit); //! the parent tree is per-cell LWW `parent_id`/`column_id`. Everything here //! writes WITHOUT committing — the caller (the gated flow runtime, or a test) @@ -336,7 +336,7 @@ pub fn sheet_order_ids(doc: &LoroDoc) -> Vec { // ---- Cell rich text -------------------------------------------------------- /// Ensure the cell's flow container exists in the EXACT .db8 flow shape -/// (sentinel text container + attrs + text_container_id + a per-cell +/// (sentinel text container + attrs + `text_container_id` + a per-cell /// paragraph registry child). pub fn ensure_cell_flow(cell: &LoroMap) -> LoroResult { let flow = ensure_flow(cell, CELL_FLOW_KEY, CELL_FLOW_KIND)?; @@ -396,11 +396,7 @@ pub fn cell_flow_from_paragraphs(cell: &LoroMap, paragraphs: &[InputParagraph]) for (ix, paragraph) in paragraphs.iter().enumerate() { let boundary = boundaries[ix]; - text.mark( - boundary..boundary + 1, - MARK_PARAGRAPH_STYLE, - paragraph_style_value(paragraph.style), - )?; + text.mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style))?; let mut unicode_pos = boundary + 1; for run in ¶graph.runs { let run_len = run.text.chars().count(); @@ -438,7 +434,9 @@ pub fn write_paragraph_record(registry: &LoroMap, text: &LoroText, key: &str, bo /// Apply `styles` as explicit marks over `range` (unicode). The inverse of /// `run_styles_from_attrs`; only non-default fields emit marks. pub fn mark_run_styles(text: &LoroText, range: std::ops::Range, styles: &gpui_flowtext::RunStyles) -> LoroResult<()> { - use flowstate_document::loro_schema::{MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, MARK_VERT_ALIGN}; + use flowstate_document::loro_schema::{ + MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, MARK_VERT_ALIGN, + }; if let RunSemanticStyle::Custom(slot) = styles.semantic { text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; } diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index a124649e..c2c4bc52 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -42,7 +42,13 @@ pub fn decode_fl0_snapshot(bytes: &[u8]) -> anyhow::Result> { if version == 1 { // v1 was a pre-release format that never shipped; there is nothing to // migrate. Loud, specific message instead of a generic version error. - return Err(io::Error::new(io::ErrorKind::InvalidData, "this .fl0 uses the pre-release v1 format, which never shipped and cannot be opened").into()); + return Err( + io::Error::new( + io::ErrorKind::InvalidData, + "this .fl0 uses the pre-release v1 format, which never shipped and cannot be opened", + ) + .into(), + ); } if version != FL0_VERSION { return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported .fl0 version {version}")).into()); diff --git a/crates/flowstate-flow/tests/v2_board.rs b/crates/flowstate-flow/tests/v2_board.rs index f6a817e3..d4a3b39b 100644 --- a/crates/flowstate-flow/tests/v2_board.rs +++ b/crates/flowstate-flow/tests/v2_board.rs @@ -4,457 +4,469 @@ //! schema property — a reorder writes only the order list and can never //! clobber a concurrent text edit. -use flowstate_flow::format::{CellId, FlowFormat, SheetId}; -use flowstate_flow::loro_projection::{FlowDefect, materialize_board, materialize_cell_rows, summary_from_rows}; -use flowstate_flow::loro_schema::{ - self, cell_flow_from_paragraphs, cell_map, init_flow_document, move_cell_order, read_document_id, read_schema_version, remove_cell, - remove_sheet, seed_cell_flow, sheet_map, write_cell, write_sheet, -}; -use flowstate_flow::{decode_fl0_snapshot, encode_fl0_snapshot}; -use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; -use loro::{ExportMode, LoroDoc}; -use uuid::Uuid; - -fn new_doc(format: &FlowFormat) -> LoroDoc { - let doc = LoroDoc::new(); - init_flow_document(&doc, format).expect("init flow doc"); - doc.commit(); - doc -} - -fn fork(doc: &LoroDoc) -> LoroDoc { - let snapshot = doc.export(ExportMode::Snapshot).expect("export snapshot"); - let forked = LoroDoc::new(); - loro_schema::configure_flow_text_styles(&forked); - forked.import(&snapshot).expect("import snapshot"); - forked -} +#[cfg(test)] +mod tests { + use flowstate_flow::format::{CellId, FlowFormat, SheetId}; + use flowstate_flow::loro_projection::{FlowDefect, materialize_board, materialize_cell_rows, summary_from_rows}; + use flowstate_flow::loro_schema::{ + self, cell_flow_from_paragraphs, cell_map, init_flow_document, move_cell_order, read_document_id, read_schema_version, remove_cell, + remove_sheet, seed_cell_flow, sheet_map, write_cell, write_sheet, + }; + use flowstate_flow::{decode_fl0_snapshot, encode_fl0_snapshot}; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use loro::{ExportMode, LoroDoc}; + use uuid::Uuid; + + fn new_doc(format: &FlowFormat) -> LoroDoc { + let doc = LoroDoc::new(); + init_flow_document(&doc, format).expect("init flow doc"); + doc.commit(); + doc + } -fn exchange(a: &LoroDoc, b: &LoroDoc) { - let for_b = a.export(ExportMode::updates(&b.oplog_vv())).expect("export a→b"); - let for_a = b.export(ExportMode::updates(&a.oplog_vv())).expect("export b→a"); - a.import(&for_a).expect("import into a"); - b.import(&for_b).expect("import into b"); -} + fn fork(doc: &LoroDoc) -> LoroDoc { + let snapshot = doc.export(ExportMode::Snapshot).expect("export snapshot"); + let forked = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&forked); + forked.import(&snapshot).expect("import snapshot"); + forked + } -fn boards_equal(a: &LoroDoc, b: &LoroDoc) { - let (board_a, _) = materialize_board(a).expect("materialize a"); - let (board_b, _) = materialize_board(b).expect("materialize b"); - assert_eq!(board_a, board_b, "peers sharing canonical state must materialize identical boards"); -} + fn exchange(a: &LoroDoc, b: &LoroDoc) { + let for_b = a + .export(ExportMode::updates(&b.oplog_vv())) + .expect("export a→b"); + let for_a = b + .export(ExportMode::updates(&a.oplog_vv())) + .expect("export b→a"); + a.import(&for_a).expect("import into a"); + b.import(&for_b).expect("import into b"); + } -fn run(text: &str) -> InputRun { - InputRun { - text: text.into(), - styles: RunStyles::default(), + fn boards_equal(a: &LoroDoc, b: &LoroDoc) { + let (board_a, _) = materialize_board(a).expect("materialize a"); + let (board_b, _) = materialize_board(b).expect("materialize b"); + assert_eq!(board_a, board_b, "peers sharing canonical state must materialize identical boards"); } -} -fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { - InputParagraph { style, runs } -} + fn run(text: &str) -> InputRun { + InputRun { + text: text.into(), + styles: RunStyles::default(), + } + } -struct Board { - doc: LoroDoc, - format: FlowFormat, - sheet: SheetId, -} + fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { + InputParagraph { style, runs } + } -fn board_with_sheet() -> Board { - let format = FlowFormat::policy_debate(); - let doc = new_doc(&format); - let sheet = Uuid::new_v4(); - write_sheet(&doc, sheet, "Case", format.sheet_types[0].id, 0).expect("write sheet"); - doc.commit(); - Board { doc, format, sheet } -} + struct Board { + doc: LoroDoc, + format: FlowFormat, + sheet: SheetId, + } -impl Board { - fn add_cell(&self, column_index: usize, parent: Option, order_index: usize) -> CellId { - let cell_id = Uuid::new_v4(); - let column = self.format.sheet_types[0].columns[column_index].id; - let cell = write_cell(&self.doc, self.sheet, cell_id, column, parent, order_index).expect("write cell"); - seed_cell_flow(&cell).expect("seed cell flow"); - self.doc.commit(); - cell_id + fn board_with_sheet() -> Board { + let format = FlowFormat::policy_debate(); + let doc = new_doc(&format); + let sheet = Uuid::new_v4(); + write_sheet(&doc, sheet, "Case", format.sheet_types[0].id, 0).expect("write sheet"); + doc.commit(); + Board { doc, format, sheet } } -} -#[test] -fn fresh_board_materializes_and_round_trips_through_snapshot() { - let board = board_with_sheet(); - let root = board.add_cell(0, None, usize::MAX); - let child = board.add_cell(1, Some(root), usize::MAX); - let cell = cell_map(&board.doc, root).expect("cell map"); - cell_flow_from_paragraphs( - &cell, - &[ - paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag line")]), - paragraph(ParagraphStyle::Normal, vec![run("body text")]), - ], - ) - .expect("write cell content"); - board.doc.commit(); - - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - assert_eq!(defects, Vec::new(), "well-formed board materializes without defects"); - assert_eq!(projection.format, board.format); - assert_eq!(projection.sheets.len(), 1); - let sheet = &projection.sheets[0]; - assert_eq!(sheet.name, "Case"); - assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![root, child]); - assert_eq!(sheet.cells[1].parent_id, Some(root)); - let summary = &sheet.cells[0].summary; - assert_eq!(summary.summary_text.as_ref(), "Tag line"); - assert!(summary.uses_summary_projection); - assert!(!summary.is_empty); - // The fresh child seeded with the canonical empty tag row. - assert!(sheet.cells[1].summary.is_empty); - assert!(sheet.cells[1].summary.uses_summary_projection); - - // Snapshot round trip (the join path): identical board on a fresh doc. - let restored = fork(&board.doc); - assert_eq!(read_schema_version(&restored), Some(2)); - assert_eq!(read_document_id(&restored), read_document_id(&board.doc)); - boards_equal(&board.doc, &restored); -} + impl Board { + fn add_cell(&self, column_index: usize, parent: Option, order_index: usize) -> CellId { + let cell_id = Uuid::new_v4(); + let column = self.format.sheet_types[0].columns[column_index].id; + let cell = write_cell(&self.doc, self.sheet, cell_id, column, parent, order_index).expect("write cell"); + seed_cell_flow(&cell).expect("seed cell flow"); + self.doc.commit(); + cell_id + } + } -/// THE headline schema property: a reorder writes only the order list, so it -/// can never clobber a concurrent text edit in a moved cell. -#[test] -fn reorder_never_clobbers_a_concurrent_cell_edit() { - let board = board_with_sheet(); - let first = board.add_cell(0, None, usize::MAX); - let second = board.add_cell(0, None, usize::MAX); - - let peer_a = fork(&board.doc); - let peer_b = fork(&board.doc); - - // Peer A moves `first` after `second` (order-list-only write). - let sheet_a = sheet_map(&peer_a, board.sheet).expect("sheet map"); - move_cell_order(&sheet_a, 0, 1).expect("move"); - peer_a.commit(); - - // Peer B concurrently replaces `first`'s text. - let cell_b = cell_map(&peer_b, first).expect("cell map"); - cell_flow_from_paragraphs(&cell_b, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("surviving edit")])]).expect("edit"); - peer_b.commit(); - - exchange(&peer_a, &peer_b); - boards_equal(&peer_a, &peer_b); - - let (merged, defects) = materialize_board(&peer_a).expect("materialize merged"); - assert_eq!(defects, Vec::new()); - let sheet = &merged.sheets[0]; - assert_eq!( - sheet.cells.iter().map(|cell| cell.id).collect::>(), - vec![second, first], - "the reorder survived" - ); - assert_eq!( - sheet.cell(first).expect("moved cell").summary.summary_text.as_ref(), - "surviving edit", - "the concurrent text edit survived the reorder" - ); -} + #[test] + fn fresh_board_materializes_and_round_trips_through_snapshot() { + let board = board_with_sheet(); + let root = board.add_cell(0, None, usize::MAX); + let child = board.add_cell(1, Some(root), usize::MAX); + let cell = cell_map(&board.doc, root).expect("cell map"); + cell_flow_from_paragraphs( + &cell, + &[ + paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag line")]), + paragraph(ParagraphStyle::Normal, vec![run("body text")]), + ], + ) + .expect("write cell content"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "well-formed board materializes without defects"); + assert_eq!(projection.format, board.format); + assert_eq!(projection.sheets.len(), 1); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.name, "Case"); + assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![root, child]); + assert_eq!(sheet.cells[1].parent_id, Some(root)); + let summary = &sheet.cells[0].summary; + assert_eq!(summary.summary_text.as_ref(), "Tag line"); + assert!(summary.uses_summary_projection); + assert!(!summary.is_empty); + // The fresh child seeded with the canonical empty tag row. + assert!(sheet.cells[1].summary.is_empty); + assert!(sheet.cells[1].summary.uses_summary_projection); + + // Snapshot round trip (the join path): identical board on a fresh doc. + let restored = fork(&board.doc); + assert_eq!(read_schema_version(&restored), Some(2)); + assert_eq!(read_document_id(&restored), read_document_id(&board.doc)); + boards_equal(&board.doc, &restored); + } -#[test] -fn concurrent_char_edits_in_one_cell_merge() { - let board = board_with_sheet(); - let cell_id = board.add_cell(0, None, usize::MAX); - let cell = cell_map(&board.doc, cell_id).expect("cell map"); - cell_flow_from_paragraphs(&cell, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("shared")])]).expect("seed text"); - board.doc.commit(); - - let peer_a = fork(&board.doc); - let peer_b = fork(&board.doc); - let text_a = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_a, cell_id).unwrap()) - .unwrap() - .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) - .unwrap(); - let text_b = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_b, cell_id).unwrap()) - .unwrap() - .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) - .unwrap(); - // "\nshared": A prepends at the paragraph head, B appends at the tail. - text_a.insert(1, "A-").expect("insert a"); - peer_a.commit(); - text_b.insert(7, "-B").expect("insert b"); - peer_b.commit(); - - exchange(&peer_a, &peer_b); - boards_equal(&peer_a, &peer_b); - let rows = materialize_cell_rows(&peer_a, cell_id).expect("cell rows"); - let summary = summary_from_rows(&rows.blocks); - assert_eq!(summary.summary_text.as_ref(), "A-shared-B", "same-cell edits interleave char-level"); -} + /// THE headline schema property: a reorder writes only the order list, so it + /// can never clobber a concurrent text edit in a moved cell. + #[test] + fn reorder_never_clobbers_a_concurrent_cell_edit() { + let board = board_with_sheet(); + let first = board.add_cell(0, None, usize::MAX); + let second = board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + + // Peer A moves `first` after `second` (order-list-only write). + let sheet_a = sheet_map(&peer_a, board.sheet).expect("sheet map"); + move_cell_order(&sheet_a, 0, 1).expect("move"); + peer_a.commit(); + + // Peer B concurrently replaces `first`'s text. + let cell_b = cell_map(&peer_b, first).expect("cell map"); + cell_flow_from_paragraphs(&cell_b, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("surviving edit")])]).expect("edit"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + + let (merged, defects) = materialize_board(&peer_a).expect("materialize merged"); + assert_eq!(defects, Vec::new()); + let sheet = &merged.sheets[0]; + assert_eq!( + sheet.cells.iter().map(|cell| cell.id).collect::>(), + vec![second, first], + "the reorder survived" + ); + assert_eq!( + sheet + .cell(first) + .expect("moved cell") + .summary + .summary_text + .as_ref(), + "surviving edit", + "the concurrent text edit survived the reorder" + ); + } -#[test] -fn cycle_break_orphans_the_greatest_uuid_member_deterministically() { - let board = board_with_sheet(); - let a = board.add_cell(0, None, usize::MAX); - let b = board.add_cell(1, Some(a), usize::MAX); - // Raw-write the cycle: a.parent = b (b is already a's child). - let cell_a = cell_map(&board.doc, a).expect("cell a"); - loro_schema::set_cell_parent(&cell_a, Some(b)).expect("cycle write"); - board.doc.commit(); - - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - let broken = a.max(b); - assert!( - defects - .iter() - .any(|defect| matches!(defect, FlowDefect::ParentCycle { cell } if *cell == broken)), - "greatest-uuid member reported: {defects:?}" - ); - let sheet = &projection.sheets[0]; - assert_eq!(sheet.cell(broken).expect("broken cell").parent_id, None); - // Determinism: a fresh fork materializes the identical board. - boards_equal(&board.doc, &fork(&board.doc)); -} + #[test] + fn concurrent_char_edits_in_one_cell_merge() { + let board = board_with_sheet(); + let cell_id = board.add_cell(0, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + cell_flow_from_paragraphs(&cell, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("shared")])]).expect("seed text"); + board.doc.commit(); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + let text_a = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_a, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + let text_b = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_b, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + // "\nshared": A prepends at the paragraph head, B appends at the tail. + text_a.insert(1, "A-").expect("insert a"); + peer_a.commit(); + text_b.insert(7, "-B").expect("insert b"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let rows = materialize_cell_rows(&peer_a, cell_id).expect("cell rows"); + let summary = summary_from_rows(&rows.blocks); + assert_eq!(summary.summary_text.as_ref(), "A-shared-B", "same-cell edits interleave char-level"); + } -#[test] -fn dangling_parent_is_orphaned() { - let board = board_with_sheet(); - let cell_id = board.add_cell(1, None, usize::MAX); - let cell = cell_map(&board.doc, cell_id).expect("cell map"); - loro_schema::set_cell_parent(&cell, Some(Uuid::new_v4())).expect("dangling write"); - board.doc.commit(); - - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - assert!( - defects - .iter() - .any(|defect| matches!(defect, FlowDefect::DanglingParent { cell } if *cell == cell_id)) - ); - assert_eq!(projection.sheets[0].cell(cell_id).unwrap().parent_id, None); -} + #[test] + fn cycle_break_orphans_the_greatest_uuid_member_deterministically() { + let board = board_with_sheet(); + let a = board.add_cell(0, None, usize::MAX); + let b = board.add_cell(1, Some(a), usize::MAX); + // Raw-write the cycle: a.parent = b (b is already a's child). + let cell_a = cell_map(&board.doc, a).expect("cell a"); + loro_schema::set_cell_parent(&cell_a, Some(b)).expect("cycle write"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + let broken = a.max(b); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::ParentCycle { cell } if *cell == broken)), + "greatest-uuid member reported: {defects:?}" + ); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.cell(broken).expect("broken cell").parent_id, None); + // Determinism: a fresh fork materializes the identical board. + boards_equal(&board.doc, &fork(&board.doc)); + } -#[test] -fn column_adjacency_violation_orphans_the_child() { - let board = board_with_sheet(); - let parent = board.add_cell(0, None, usize::MAX); - // Child two columns right of its parent (adjacency requires exactly one). - let child = board.add_cell(2, Some(parent), usize::MAX); + #[test] + fn dangling_parent_is_orphaned() { + let board = board_with_sheet(); + let cell_id = board.add_cell(1, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + loro_schema::set_cell_parent(&cell, Some(Uuid::new_v4())).expect("dangling write"); + board.doc.commit(); - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - assert!( - defects - .iter() - .any(|defect| matches!(defect, FlowDefect::ColumnAdjacency { cell } if *cell == child)) - ); - assert_eq!(projection.sheets[0].cell(child).unwrap().parent_id, None); -} + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::DanglingParent { cell } if *cell == cell_id)) + ); + assert_eq!(projection.sheets[0].cell(cell_id).unwrap().parent_id, None); + } -#[test] -fn split_sibling_run_relinearizes_to_canonical_dfs() { - let board = board_with_sheet(); - let parent = board.add_cell(0, None, usize::MAX); - let first_child = board.add_cell(1, Some(parent), usize::MAX); - let second_child = board.add_cell(1, Some(parent), usize::MAX); - let intruder = board.add_cell(1, None, usize::MAX); - // Raw order surgery: shove the parentless intruder between the two children. - let sheet = sheet_map(&board.doc, board.sheet).expect("sheet map"); - move_cell_order(&sheet, 3, 2).expect("split the run"); - board.doc.commit(); - - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - assert!( - defects - .iter() - .any(|defect| matches!(defect, FlowDefect::SiblingRunSplit { sheet } if *sheet == board.sheet)) - ); - let cells: Vec = projection.sheets[0].cells.iter().map(|cell| cell.id).collect(); - assert_eq!( - cells, - vec![parent, first_child, second_child, intruder], - "canonical DFS keeps the subtree contiguous" - ); - boards_equal(&board.doc, &fork(&board.doc)); -} + #[test] + fn column_adjacency_violation_orphans_the_child() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + // Child two columns right of its parent (adjacency requires exactly one). + let child = board.add_cell(2, Some(parent), usize::MAX); -#[test] -fn concurrent_sheet_delete_and_cell_add_converge_on_sheet_delete() { - let board = board_with_sheet(); - board.add_cell(0, None, usize::MAX); - - let peer_a = fork(&board.doc); - let peer_b = fork(&board.doc); - remove_sheet(&peer_a, board.sheet).expect("remove sheet"); - peer_a.commit(); - let orphan = Uuid::new_v4(); - let column = board.format.sheet_types[0].columns[0].id; - let cell = write_cell(&peer_b, board.sheet, orphan, column, None, usize::MAX).expect("concurrent add"); - seed_cell_flow(&cell).expect("seed"); - peer_b.commit(); - - exchange(&peer_a, &peer_b); - boards_equal(&peer_a, &peer_b); - let (projection, defects) = materialize_board(&peer_a).expect("materialize"); - // Loro map LWW: whichever way the sheet-record race resolves, both peers - // agree; with the delete winning the orphan cell is skipped + reported. - if projection.sheets.is_empty() { + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); assert!( defects .iter() - .any(|defect| matches!(defect, FlowDefect::CellRecordUnknownSheet { cell } if *cell == orphan)) + .any(|defect| matches!(defect, FlowDefect::ColumnAdjacency { cell } if *cell == child)) ); + assert_eq!(projection.sheets[0].cell(child).unwrap().parent_id, None); } -} -#[test] -fn delete_cell_removes_record_and_order() { - let board = board_with_sheet(); - let stays = board.add_cell(0, None, usize::MAX); - let goes = board.add_cell(0, None, usize::MAX); - remove_cell(&board.doc, board.sheet, goes).expect("remove"); - board.doc.commit(); - - let (projection, defects) = materialize_board(&board.doc).expect("materialize"); - assert_eq!(defects, Vec::new(), "a clean delete leaves no liveness defect"); - assert_eq!( - projection.sheets[0] + #[test] + fn split_sibling_run_relinearizes_to_canonical_dfs() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + let first_child = board.add_cell(1, Some(parent), usize::MAX); + let second_child = board.add_cell(1, Some(parent), usize::MAX); + let intruder = board.add_cell(1, None, usize::MAX); + // Raw order surgery: shove the parentless intruder between the two children. + let sheet = sheet_map(&board.doc, board.sheet).expect("sheet map"); + move_cell_order(&sheet, 3, 2).expect("split the run"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::SiblingRunSplit { sheet } if *sheet == board.sheet)) + ); + let cells: Vec = projection.sheets[0] .cells .iter() .map(|cell| cell.id) - .collect::>(), - vec![stays] - ); -} - -/// Normalization determinism property: forked docs take random RAW mutations -/// (moves, parent rewrites, deletes racing adds — no invariant checked at -/// write time), cross-import, and must materialize byte-equal, defect-equal -/// boards; and materializing twice is a fixpoint. -#[test] -fn normalization_determinism_under_random_concurrent_raw_mutation() { - let mut rng = 0x1234_5678_9abc_def0_u64; - let mut next = move || { - rng ^= rng << 13; - rng ^= rng >> 7; - rng ^= rng << 17; - rng - }; + .collect(); + assert_eq!( + cells, + vec![parent, first_child, second_child, intruder], + "canonical DFS keeps the subtree contiguous" + ); + boards_equal(&board.doc, &fork(&board.doc)); + } - for round in 0..60 { + #[test] + fn concurrent_sheet_delete_and_cell_add_converge_on_sheet_delete() { let board = board_with_sheet(); - let mut cells: Vec = Vec::new(); - for _ in 0..6 { - let column = (next() % 3) as usize; - let parent = if column > 0 && next() % 2 == 0 && !cells.is_empty() { - Some(cells[(next() % cells.len() as u64) as usize]) - } else { - None - }; - cells.push(board.add_cell(column, parent, usize::MAX)); + board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + remove_sheet(&peer_a, board.sheet).expect("remove sheet"); + peer_a.commit(); + let orphan = Uuid::new_v4(); + let column = board.format.sheet_types[0].columns[0].id; + let cell = write_cell(&peer_b, board.sheet, orphan, column, None, usize::MAX).expect("concurrent add"); + seed_cell_flow(&cell).expect("seed"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let (projection, defects) = materialize_board(&peer_a).expect("materialize"); + // Loro map LWW: whichever way the sheet-record race resolves, both peers + // agree; with the delete winning the orphan cell is skipped + reported. + if projection.sheets.is_empty() { + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::CellRecordUnknownSheet { cell } if *cell == orphan)) + ); } + } + + #[test] + fn delete_cell_removes_record_and_order() { + let board = board_with_sheet(); + let stays = board.add_cell(0, None, usize::MAX); + let goes = board.add_cell(0, None, usize::MAX); + remove_cell(&board.doc, board.sheet, goes).expect("remove"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "a clean delete leaves no liveness defect"); + assert_eq!( + projection.sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect::>(), + vec![stays] + ); + } + + /// Normalization determinism property: forked docs take random RAW mutations + /// (moves, parent rewrites, deletes racing adds — no invariant checked at + /// write time), cross-import, and must materialize byte-equal, defect-equal + /// boards; and materializing twice is a fixpoint. + #[test] + fn normalization_determinism_under_random_concurrent_raw_mutation() { + let mut rng = 0x1234_5678_9abc_def0_u64; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + + for round in 0..60 { + let board = board_with_sheet(); + let mut cells: Vec = Vec::new(); + for _ in 0..6 { + let column = (next() % 3) as usize; + let parent = (column > 0 && next() % 2 == 0 && !cells.is_empty()).then(|| cells[(next() % cells.len() as u64) as usize]); + cells.push(board.add_cell(column, parent, usize::MAX)); + } - let peers = [fork(&board.doc), fork(&board.doc), fork(&board.doc)]; - for peer in &peers { - for _ in 0..4 { - let choice = next() % 5; - let victim = cells[(next() % cells.len() as u64) as usize]; - match choice { - 0 => { - if let Some(sheet) = sheet_map(peer, board.sheet) { - let len = loro_schema::cell_order_ids(&sheet).len(); - if len > 1 { - let from = (next() % len as u64) as usize; - let to = (next() % len as u64) as usize; - let _ = move_cell_order(&sheet, from, to); + let peers = [fork(&board.doc), fork(&board.doc), fork(&board.doc)]; + for peer in &peers { + for _ in 0..4 { + let choice = next() % 5; + let victim = cells[(next() % cells.len() as u64) as usize]; + match choice { + 0 => { + if let Some(sheet) = sheet_map(peer, board.sheet) { + let len = loro_schema::cell_order_ids(&sheet).len(); + if len > 1 { + let from = (next() % len as u64) as usize; + let to = (next() % len as u64) as usize; + let _ = move_cell_order(&sheet, from, to); + } } - } - }, - 1 => { - if let Some(cell) = cell_map(peer, victim) { - let parent = cells[(next() % cells.len() as u64) as usize]; - let _ = loro_schema::set_cell_parent(&cell, Some(parent)); - } - }, - 2 => { - if let Some(cell) = cell_map(peer, victim) { - let column = board.format.sheet_types[0].columns[(next() % 4) as usize].id; - let _ = loro_schema::set_cell_column(&cell, column); - } - }, - 3 => { - let _ = remove_cell(peer, board.sheet, victim); - }, - _ => { - if let Some(cell) = cell_map(peer, victim) { - let _ = cell_flow_from_paragraphs( - &cell, - &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run(&format!("edit {round}"))])], - ); - } - }, + }, + 1 => { + if let Some(cell) = cell_map(peer, victim) { + let parent = cells[(next() % cells.len() as u64) as usize]; + let _ = loro_schema::set_cell_parent(&cell, Some(parent)); + } + }, + 2 => { + if let Some(cell) = cell_map(peer, victim) { + let column = board.format.sheet_types[0].columns[(next() % 4) as usize].id; + let _ = loro_schema::set_cell_column(&cell, column); + } + }, + 3 => { + let _ = remove_cell(peer, board.sheet, victim); + }, + _ => { + if let Some(cell) = cell_map(peer, victim) { + let _ = cell_flow_from_paragraphs( + &cell, + &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run(&format!("edit {round}"))])], + ); + } + }, + } + peer.commit(); } - peer.commit(); } - } - // Full mesh exchange until quiescent (two rounds suffice for 3 peers). - for _ in 0..2 { - exchange(&peers[0], &peers[1]); - exchange(&peers[1], &peers[2]); - exchange(&peers[0], &peers[2]); - } + // Full mesh exchange until quiescent (two rounds suffice for 3 peers). + for _ in 0..2 { + exchange(&peers[0], &peers[1]); + exchange(&peers[1], &peers[2]); + exchange(&peers[0], &peers[2]); + } - let materialized: Vec<_> = peers - .iter() - .map(|peer| materialize_board(peer).expect("materialize")) - .collect(); - assert_eq!(materialized[0], materialized[1], "round {round}: peers 0/1 diverged"); - assert_eq!(materialized[1], materialized[2], "round {round}: peers 1/2 diverged"); - // Fixpoint: materializing again yields the identical board. - let again = materialize_board(&peers[0]).expect("materialize again"); - assert_eq!(materialized[0].0, again.0, "round {round}: materializer is not a pure function"); + let materialized: Vec<_> = peers + .iter() + .map(|peer| materialize_board(peer).expect("materialize")) + .collect(); + assert_eq!(materialized[0], materialized[1], "round {round}: peers 0/1 diverged"); + assert_eq!(materialized[1], materialized[2], "round {round}: peers 1/2 diverged"); + // Fixpoint: materializing again yields the identical board. + let again = materialize_board(&peers[0]).expect("materialize again"); + assert_eq!(materialized[0].0, again.0, "round {round}: materializer is not a pure function"); + } } -} -/// The old `Cell::summary_text` fixture, verbatim: mixed card + analytic -/// content projects tag/cite/undertag/analytic rows in document order. -#[test] -fn summary_projects_mixed_card_and_analytic_content_in_document_order() { - let cite = |text: &str| { - let mut run = run(text); - run.styles.semantic = flowstate_document::SEMANTIC_CITE; - run - }; - let blocks: Vec = vec![ - gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag")])), - gpui_flowtext::InputBlock::Paragraph(paragraph( - ParagraphStyle::Normal, - vec![run("hidden before "), cite("Cite"), run(" hidden after")], - )), - gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_UNDERTAG, vec![run("Undertag")])), - gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_ANALYTIC, vec![run("Analytic")])), - ]; - let summary = summary_from_rows(&blocks); - assert_eq!(summary.summary_text.as_ref(), "Tag\nCite\nUndertag\nAnalytic"); - assert!(summary.uses_summary_projection); - assert!(!summary.struck); - assert!(!summary.is_empty); -} + /// The old `Cell::summary_text` fixture, verbatim: mixed card + analytic + /// content projects tag/cite/undertag/analytic rows in document order. + #[test] + fn summary_projects_mixed_card_and_analytic_content_in_document_order() { + let cite = |text: &str| { + let mut run = run(text); + run.styles.semantic = flowstate_document::SEMANTIC_CITE; + run + }; + let blocks: Vec = vec![ + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph( + ParagraphStyle::Normal, + vec![run("hidden before "), cite("Cite"), run(" hidden after")], + )), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_UNDERTAG, vec![run("Undertag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_ANALYTIC, vec![run("Analytic")])), + ]; + let summary = summary_from_rows(&blocks); + assert_eq!(summary.summary_text.as_ref(), "Tag\nCite\nUndertag\nAnalytic"); + assert!(summary.uses_summary_projection); + assert!(!summary.struck); + assert!(!summary.is_empty); + } -#[test] -fn fl0_v2_round_trips_and_v1_is_rejected() { - let board = board_with_sheet(); - board.add_cell(0, None, usize::MAX); - let snapshot = board.doc.export(ExportMode::Snapshot).expect("export"); - let encoded = encode_fl0_snapshot(&snapshot); - let decoded = decode_fl0_snapshot(&encoded).expect("decode"); - let restored = LoroDoc::new(); - loro_schema::configure_flow_text_styles(&restored); - restored.import(&decoded).expect("import"); - assert_eq!(read_schema_version(&restored), Some(2)); - boards_equal(&board.doc, &restored); - - // v1 rejection: same magic, version 1. - let mut v1 = Vec::new(); - v1.extend_from_slice(b"FLOWFL0\0"); - v1.extend_from_slice(&1u32.to_le_bytes()); - v1.extend_from_slice(&0u64.to_le_bytes()); - let error = decode_fl0_snapshot(&v1).expect_err("v1 must be rejected"); - assert!(error.to_string().contains("never shipped"), "{error}"); + #[test] + fn fl0_v2_round_trips_and_v1_is_rejected() { + let board = board_with_sheet(); + board.add_cell(0, None, usize::MAX); + let snapshot = board.doc.export(ExportMode::Snapshot).expect("export"); + let encoded = encode_fl0_snapshot(&snapshot); + let decoded = decode_fl0_snapshot(&encoded).expect("decode"); + let restored = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&restored); + restored.import(&decoded).expect("import"); + assert_eq!(read_schema_version(&restored), Some(2)); + boards_equal(&board.doc, &restored); + + // v1 rejection: same magic, version 1. + let mut v1 = Vec::new(); + v1.extend_from_slice(b"FLOWFL0\0"); + v1.extend_from_slice(&1u32.to_le_bytes()); + v1.extend_from_slice(&0u64.to_le_bytes()); + let error = decode_fl0_snapshot(&v1).expect_err("v1 must be rejected"); + assert!(error.to_string().contains("never shipped"), "{error}"); + } } diff --git a/crates/flowstate-soak/Cargo.toml b/crates/flowstate-soak/Cargo.toml index 23a25a9d..bd39b159 100644 --- a/crates/flowstate-soak/Cargo.toml +++ b/crates/flowstate-soak/Cargo.toml @@ -10,14 +10,31 @@ clap.workspace = true flowstate-collab.workspace = true flowstate-document.workspace = true flowstate-docx.workspace = true +flowstate-flow.workspace = true +gpui-flowtext.workspace = true hotpath.workspace = true loro.workspace = true postcard.workspace = true +uuid.workspace = true [features] -hotpath = ["hotpath/hotpath", "flowstate-collab/hotpath", "flowstate-docx/hotpath", "flowstate-document/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-collab/hotpath-cpu", "flowstate-docx/hotpath-cpu", "flowstate-document/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-collab/hotpath-alloc", "flowstate-docx/hotpath-alloc", "flowstate-document/hotpath-alloc"] +hotpath = ["hotpath/hotpath", "flowstate-collab/hotpath", "flowstate-docx/hotpath", "flowstate-document/hotpath", "flowstate-flow/hotpath"] +hotpath-cpu = [ + "hotpath", + "hotpath/hotpath-cpu", + "flowstate-collab/hotpath-cpu", + "flowstate-docx/hotpath-cpu", + "flowstate-document/hotpath-cpu", + "flowstate-flow/hotpath-cpu", +] +hotpath-alloc = [ + "hotpath", + "hotpath/hotpath-alloc", + "flowstate-collab/hotpath-alloc", + "flowstate-docx/hotpath-alloc", + "flowstate-document/hotpath-alloc", + "flowstate-flow/hotpath-alloc", +] [lints] workspace = true diff --git a/crates/flowstate-soak/src/bin/flow_soak.rs b/crates/flowstate-soak/src/bin/flow_soak.rs new file mode 100644 index 00000000..b064b76b --- /dev/null +++ b/crates/flowstate-soak/src/bin/flow_soak.rs @@ -0,0 +1,281 @@ +//! Two-peer flow (.fl0) collaboration soak (flow spec build-order step 12). +//! +//! Both peers drive the REAL gated write path — structural `FlowIntent`s on +//! the board plus same-cell char-level typing through a `FlowCellAuthority` +//! (exactly what an attached `RichTextEditor` does) — and exchange Loro update +//! bytes in rounds like the session I/O pump. Prints wall-clock distributions +//! per stage and ends with the convergence proof: both maintained boards +//! byte-equal each other AND a fresh materialization of canonical state, and +//! the shared cell's text identical on both peers. +//! +//! ```text +//! cargo run -p flowstate-soak --release --bin flow_soak -- [--rounds N] [--keystrokes-per-round N] +//! ``` + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use clap::Parser; +use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; +use flowstate_collab::local_write::WriteGate; +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; +use flowstate_flow::loro_projection::materialize_board; +use gpui_flowtext::local_intents::{InsertTextIntent, LocalIntent, LocalWriteAuthority as _, TextAnchor}; +use uuid::Uuid; + +/// Two-peer flow collaboration hotpath soak. +#[derive(Parser)] +#[command(name = "flow-soak")] +struct Cli { + /// Sync rounds (each round: typing + one structural op per peer, then a + /// full update exchange). + #[arg(long, default_value_t = 200)] + rounds: usize, + /// Same-cell keystrokes per peer per round. + #[arg(long, default_value_t = 8)] + keystrokes_per_round: usize, +} + +struct Peer { + name: &'static str, + handle: Arc, + authority: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, +} + +impl Peer { + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } +} + +#[hotpath::main(functions_limit = 60)] +fn main() { + let cli = Cli::parse(); + run(cli.rounds, cli.keystrokes_per_round).expect("flow soak failed"); +} + +fn run(rounds: usize, keystrokes_per_round: usize) -> anyhow::Result<()> { + let build_profile = if cfg!(debug_assertions) { "debug" } else { "release" }; + println!("flow collab soak — build profile: {build_profile}, rounds: {rounds}, keystrokes/round/peer: {keystrokes_per_round}"); + + let (peers, sheet, shared_cell) = spawn_two_peers()?; + let [a, b] = peers; + + let mut commit_times = Vec::with_capacity(rounds * keystrokes_per_round * 2); + let mut structural_times = Vec::with_capacity(rounds * 2); + let mut import_times = Vec::with_capacity(rounds * 2); + let mut round_cells: Vec = Vec::new(); + + for round in 0..rounds { + // Concurrent same-cell typing on BOTH peers before any sync — the + // headline char-merge shape. + for (peer, seed_char) in [(&a, b'a'), (&b, b'n')] { + for i in 0..keystrokes_per_round { + let projection = peer + .authority + .canonical_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + let paragraph = projection.ids.paragraph_ids[0]; + let started = Instant::now(); + peer + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, usize::MAX), + text: ((seed_char + ((round + i) % 13) as u8) as char).to_string(), + style_override: None, + })) + .map_err(|error| anyhow::anyhow!("cell keystroke rejected: {error}"))?; + commit_times.push(started.elapsed()); + } + } + + // One structural op per peer: A appends a cell, B moves the shared cell + // relative to it (reorder-vs-edit, the second headline shape). + let new_cell = Uuid::new_v4(); + let started = Instant::now(); + a.handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: new_cell, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .map_err(|error| anyhow::anyhow!("add-cell rejected: {error}"))?; + structural_times.push(started.elapsed()); + round_cells.push(new_cell); + if let Some(target) = round_cells.get(round.saturating_sub(1)).copied() + && target != shared_cell + { + let started = Instant::now(); + // Racing against A's concurrent append — a rejection (target not yet + // known to B) is expected traffic, anything else is a bug. + match b.handle.apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: shared_cell, + drop: FlowDropIntent::AfterSibling(target), + }) { + Ok(_) => structural_times.push(started.elapsed()), + Err(error) => { + let diagnostic = format!("{error}"); + anyhow::ensure!( + diagnostic.contains("cell") || diagnostic.contains("sheet"), + "unexpected move rejection: {diagnostic}" + ); + }, + } + } + + // Update exchange, both directions (the session pump shape). + for (from, to) in [(&a, &b), (&b, &a)] { + let vv = to.oplog_vv(); + let bytes = from + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + .map_err(|error| anyhow::anyhow!("export failed: {error:#}"))?; + if bytes.is_empty() { + continue; + } + let started = Instant::now(); + to.handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .map_err(|error| anyhow::anyhow!("import failed: {error:#}"))?; + import_times.push(started.elapsed()); + } + } + + // One quiescing double-exchange, then the convergence proof. + for _ in 0..2 { + for (from, to) in [(&a, &b), (&b, &a)] { + let vv = to.oplog_vv(); + let bytes = from + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + .map_err(|error| anyhow::anyhow!("export failed: {error:#}"))?; + if !bytes.is_empty() { + to.handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .map_err(|error| anyhow::anyhow!("import failed: {error:#}"))?; + } + } + } + + summarize("cell keystroke (authority commit, gate-held)", &mut commit_times); + summarize("structural intent (gate-held)", &mut structural_times); + summarize("remote import (gate-held)", &mut import_times); + + let board_a = a + .handle + .board_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + let board_b = b + .handle + .board_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + anyhow::ensure!(board_a == board_b, "peer boards diverged after quiescence"); + for peer in [&a, &b] { + let fresh = peer + .handle + .with_test_runtime(|runtime| materialize_board(runtime.doc()).map(|(board, _defects)| board)) + .map_err(|error| anyhow::anyhow!("fresh materialize failed: {error:#}"))?; + anyhow::ensure!(fresh == board_a, "{}: maintained board != fresh materialization", peer.name); + } + let text_a = cell_text(&a, shared_cell); + let text_b = cell_text(&b, shared_cell); + anyhow::ensure!(text_a == text_b, "shared cell text diverged"); + anyhow::ensure!( + text_a.chars().count() >= rounds * keystrokes_per_round * 2, + "typed characters were lost: {} < {}", + text_a.chars().count(), + rounds * keystrokes_per_round * 2, + ); + println!( + "\nconverged: {} cells, shared cell holds {} chars from both peers, boards byte-equal on both sides", + board_a.sheets[0].cells.len(), + text_a.chars().count(), + ); + Ok(()) +} + +fn spawn_two_peers() -> anyhow::Result<([Peer; 2], SheetId, CellId)> { + let format = FlowFormat::policy_debate(); + let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let sheet = Uuid::new_v4(); + let cell = Uuid::new_v4(); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Soak".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("seed sheet: {error}"))?; + seed_handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: cell, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .map_err(|error| anyhow::anyhow!("seed cell: {error}"))?; + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot())?; + let peers: Vec = ["peer-a", "peer-b"] + .into_iter() + .map(|name| -> anyhow::Result { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); + let authority = handle.cell_authority(cell); + handle + .open_cell(cell) + .map_err(|error| anyhow::anyhow!("open cell: {error}"))?; + Ok(Peer { + name, + handle, + authority, + gate, + }) + }) + .collect::>()?; + let [a, b] = peers.try_into().ok().expect("two peers"); + Ok(([a, b], sheet, cell)) +} + +fn cell_text(peer: &Peer, cell: CellId) -> String { + peer.handle.with_test_runtime(|runtime| { + let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); + rows + .blocks + .iter() + .filter_map(|block| match block { + gpui_flowtext::InputBlock::Paragraph(paragraph) => Some( + paragraph + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + ), + _ => None, + }) + .collect::>() + .join("\n") + }) +} + +fn summarize(label: &str, times: &mut [Duration]) { + if times.is_empty() { + println!("{label:<48} (no samples)"); + return; + } + times.sort_unstable(); + let pick = |q: f64| times[((times.len() - 1) as f64 * q) as usize]; + println!( + "{label:<48} n={:<5} p50={:>10.3?} p90={:>10.3?} p99={:>10.3?} max={:>10.3?}", + times.len(), + pick(0.50), + pick(0.90), + pick(0.99), + times[times.len() - 1], + ); +} diff --git a/crates/flowstate/src/collab/dropbox_checkpoint.rs b/crates/flowstate/src/collab/dropbox_checkpoint.rs index ed0591fd..cb57edcc 100644 --- a/crates/flowstate/src/collab/dropbox_checkpoint.rs +++ b/crates/flowstate/src/collab/dropbox_checkpoint.rs @@ -3,6 +3,7 @@ use std::{io, path::Path}; use flowstate_collab::{ doc_io::DocIoHandle, dropbox::{DropboxClient, DropboxWriteError}, + flow::{FlowIoHandle, FlowRuntime}, }; use loro::ExportMode; @@ -81,3 +82,72 @@ pub async fn sync_bound_checkpoint(local_path: &Path, title: String, io_handle: let refreshed = client.credentials().await; save_dropbox_collaboration(refreshed, root, true) } + +/// FLOW mirror of [`sync_bound_checkpoint`]: upload the raw `.fl0` container +/// to the bound Dropbox path (no named revisions — flows have none). On a +/// remote conflict the two Loro docs merge (CRDT import through the flow I/O +/// service), the local file is rewritten, and the merged container re-uploads +/// against the remote rev. The document-id gate prevents cross-document +/// clobbering, exactly like the .db8 path. +pub async fn sync_bound_flow_file(local_path: &Path, local_document_id: uuid::Uuid, io_handle: &FlowIoHandle) -> io::Result<()> { + let Some(mut binding) = load_dropbox_document_binding(local_path) else { + return Ok(()); + }; + let Some((credentials, root)) = load_dropbox_collaboration() else { + return Err(io::Error::new( + io::ErrorKind::NotConnected, + "flow is bound to Dropbox, but Dropbox collaboration is disconnected", + )); + }; + let client = DropboxClient::new(credentials); + let container = io_handle + .encode_bytes() + .await + .map_err(|error| io::Error::other(format!("encoding flow for Dropbox failed: {error:#}")))?; + let metadata = match client + .put_checkpoint(&binding.remote_path, container, binding.revision.as_deref()) + .await + { + Ok(metadata) => metadata, + Err(DropboxWriteError::Conflict { .. }) => { + let remote = client + .download(&binding.remote_path) + .await + .map_err(|error| io::Error::other(format!("Dropbox conflict download failed: {error:#}")))?; + let remote_snapshot = flowstate_flow::decode_fl0_snapshot(&remote.bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("Dropbox path is not a valid .fl0: {error:#}")))?; + let remote_runtime = FlowRuntime::from_snapshot(&remote_snapshot) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("Dropbox path is not a valid flow: {error:#}")))?; + if remote_runtime.document_id() != local_document_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Dropbox path contains a different Flowstate flow; it was not overwritten", + )); + } + io_handle + .import_remote_update(remote_snapshot) + .await + .map_err(|error| io::Error::other(format!("merging Dropbox flow changes failed: {error:#}")))?; + io_handle + .save_to(local_path.to_path_buf()) + .await + .map_err(|error| io::Error::other(format!("saving merged Dropbox flow failed: {error:#}")))?; + let merged = io_handle + .encode_bytes() + .await + .map_err(|error| io::Error::other(format!("encoding merged Dropbox flow failed: {error:#}")))?; + client + .put_checkpoint(&binding.remote_path, merged, Some(&remote.metadata.rev)) + .await + .map_err(|error| io::Error::other(format!("Dropbox changed again during conflict recovery: {:#}", error.into_anyhow())))? + }, + Err(DropboxWriteError::Other(error)) => { + return Err(io::Error::other(format!("saved locally, but Dropbox upload failed: {error:#}"))); + }, + }; + binding.revision = Some(metadata.rev); + save_dropbox_document_binding(binding)?; + + let refreshed = client.credentials().await; + save_dropbox_collaboration(refreshed, root, true) +} diff --git a/crates/flowstate/src/collab/manager.rs b/crates/flowstate/src/collab/manager.rs index 870e6ed0..76075e48 100644 --- a/crates/flowstate/src/collab/manager.rs +++ b/crates/flowstate/src/collab/manager.rs @@ -125,7 +125,9 @@ impl CollabManager { self.discovery_paths.insert(session, path); } self.discovery_titles.insert(session, title.clone()); - self.session_kinds.insert(session, flowstate_collab::DocumentKind::RichText); + self + .session_kinds + .insert(session, flowstate_collab::DocumentKind::RichText); let collab = CollabSession::from_local_runtime(session, panel_id, editor, title, document_io, commands.clone()); let direct_handler = collab.direct_handler(); self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) @@ -165,7 +167,9 @@ impl CollabManager { self.discovery_paths.insert(session, path); } self.discovery_titles.insert(session, title.clone()); - self.session_kinds.insert(session, flowstate_collab::DocumentKind::Flow); + self + .session_kinds + .insert(session, flowstate_collab::DocumentKind::Flow); let collab = CollabSession::from_local_flow_runtime(session, panel_id, editor, title, flow_io, commands.clone()); let direct_handler = collab.direct_handler(); self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) @@ -262,7 +266,14 @@ impl CollabManager { .map(|addr| addr.id) .ok_or_else(|| anyhow!("collaboration invite has no reachable participants"))?; self.session_kinds.insert(session, ticket.document); - let collab = CollabSession::joining(session, ticket.title.clone(), commands.clone(), bootstrap.clone(), admission.clone(), ticket.document); + let collab = CollabSession::joining( + session, + ticket.title.clone(), + commands.clone(), + bootstrap.clone(), + admission.clone(), + ticket.document, + ); let entity = cx.new(|_| collab); self.register_session(entity.clone(), cx); let neighbor_rx = entity.update(cx, |session, cx| session.prepare_join_neighbor_wait(cx)); diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index b0012858..a315c5bb 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -101,7 +101,7 @@ pub struct JoinedDocument { /// The join snapshot's materialization, per document kind. pub enum JoinedDocumentPayload { - RichText(DocumentProjection), + RichText(Box), Flow(flowstate_flow::FlowBoardProjection), } @@ -403,11 +403,19 @@ impl CollabSession { /// The rich-text arm of the attached editor (presence/comments/assets are /// rich-text-only surfaces; flow arms no-op through this accessor). pub(super) fn rich_text_editor(&self) -> Option> { - self.editor.as_ref().and_then(CollabEditor::as_rich_text).cloned() + self + .editor + .as_ref() + .and_then(CollabEditor::as_rich_text) + .cloned() } pub(super) fn flow_editor(&self) -> Option> { - self.editor.as_ref().and_then(CollabEditor::as_flow).cloned() + self + .editor + .as_ref() + .and_then(CollabEditor::as_flow) + .cloned() } pub(super) fn set_admission(&mut self, admission: SessionAdmission) { @@ -897,7 +905,7 @@ impl CollabSession { Ok(JoinedDocument { session: self.session, title: format!("{} (shared)", self.title), - payload: JoinedDocumentPayload::RichText(document), + payload: JoinedDocumentPayload::RichText(Box::new(document)), }) } @@ -1051,24 +1059,22 @@ impl CollabSession { .detach(); }, SyncIoHandle::Flow(io) => { - cx.spawn(async move |session, cx| { - match io.pump_publish().await { - Ok(events) => { - if events.is_empty() { - return; - } - let published = events.len(); - let _ = session.update(cx, |session, cx| { - fidelity::event(FidelityClass::Reconcile, "publish-pump", || { - format!("session={session_id} kind=flow events={published}") - }); - session.apply_flow_events(events, cx); + cx.spawn(async move |session, cx| match io.pump_publish().await { + Ok(events) => { + if events.is_empty() { + return; + } + let published = events.len(); + let _ = session.update(cx, |session, cx| { + fidelity::event(FidelityClass::Reconcile, "publish-pump", || { + format!("session={session_id} kind=flow events={published}") }); - }, - Err(error) => { - tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping flow publish queue failed"); - }, - } + session.apply_flow_events(events, cx); + }); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping flow publish queue failed"); + }, }) .detach(); }, diff --git a/crates/flowstate/src/collab/session_io.rs b/crates/flowstate/src/collab/session_io.rs index a5337098..73e798f2 100644 --- a/crates/flowstate/src/collab/session_io.rs +++ b/crates/flowstate/src/collab/session_io.rs @@ -74,18 +74,16 @@ impl CollabSession { // FLOW arm: one import request; the derivation rides the runtime's // streams and the pump drains vv/publish events afterwards. let session_id = self.session; - cx.spawn(async move |session, cx| { - match io.import_remote_update(bytes).await { - Ok(()) => { - let _ = session.update(cx, |session, cx| { - session.pump_publish(cx); - session.last_document_activity = std::time::Instant::now(); - }); - }, - Err(error) => { - tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "dropped unimportable remote flow update"); - }, - } + cx.spawn(async move |session, cx| match io.import_remote_update(bytes).await { + Ok(()) => { + let _ = session.update(cx, |session, cx| { + session.pump_publish(cx); + session.last_document_activity = std::time::Instant::now(); + }); + }, + Err(error) => { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "dropped unimportable remote flow update"); + }, }) .detach(); return Ok(()); @@ -387,7 +385,11 @@ impl CollabSession { // replica's own saves/packages include them. The session still never // writes document CONTENT (invariant 5) — asset bytes are content- // addressed sideband records. - if let Some(io) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) { + if let Some(io) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + { let records: Vec = asset_records .iter() .map(|(_, record)| record.clone()) diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs index d5e0e2b9..a313e49a 100644 --- a/crates/flowstate/src/collab/session_presence.rs +++ b/crates/flowstate/src/collab/session_presence.rs @@ -96,7 +96,10 @@ impl CollabSession { let selection = self .rich_text_editor() .map(|editor| editor.read(cx).selection().clone()); - let runtime = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()); + let runtime = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()); let session_id = self.session; self.presence_refresh_generation = self.presence_refresh_generation.wrapping_add(1); let generation = self.presence_refresh_generation; @@ -177,7 +180,11 @@ impl CollabSession { if self.comment_annotation_refresh_pending { return; } - let Some(runtime) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) else { + let Some(runtime) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + else { return; }; let Some(editor) = self.rich_text_editor() else { return }; @@ -246,7 +253,11 @@ impl CollabSession { flow.update(cx, |flow, cx| flow.set_external_presences(presences, cx)); return; } - let Some(runtime) = self.runtime.clone().and_then(|io| io.as_rich_text().cloned()) else { + let Some(runtime) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + else { tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text runtime is attached"); return; }; diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index e201151a..70f304c7 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -174,8 +174,7 @@ impl FlowEditor { } pub fn blank(window: &mut Window, cx: &mut Context) -> Self { - let (handle, _gate) = - FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()).expect("fresh flow document"); + let (handle, _gate) = FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()).expect("fresh flow document"); Self::new_with_path(handle, None, window, cx) } @@ -303,13 +302,7 @@ impl FlowEditor { pub fn activate_cell(&mut self, cell_id: CellId, cx: &mut Context) { let sheet_id = self.board.cell(cell_id).map(|(sheet, _)| sheet.id); if let Some(sheet_id) = sheet_id { - self.apply_intent( - FlowIntent::EnsureCellEditable { - sheet_id, - cell_id, - }, - cx, - ); + self.apply_intent(FlowIntent::EnsureCellEditable { sheet_id, cell_id }, cx); self.ensure_cell_editor(cell_id, cx); self.active_sheet = Some(sheet_id); self.active_cell = Some(cell_id); @@ -345,7 +338,7 @@ impl FlowEditor { self.add_cell_with_placement(sheet, CellPlacement::FirstResponseTo { parent: cell }, cx); } - /// The one AddCell path behind every add_* entry point. + /// The one `AddCell` path behind every `add_*` entry point. fn add_cell_with_placement(&mut self, sheet_id: SheetId, placement: CellPlacement, cx: &mut Context) { let cell_id = uuid::Uuid::new_v4(); if self.apply_intent( @@ -443,7 +436,12 @@ impl FlowEditor { let Some(sheet) = self.active_sheet else { return; }; - let Some(index) = self.board.sheets.iter().position(|candidate| candidate.id == sheet) else { + let Some(index) = self + .board + .sheets + .iter() + .position(|candidate| candidate.id == sheet) + else { return; }; let target = index diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs index 76e4eac2..e5ddceb3 100644 --- a/crates/flowstate/src/flow/editor/annotation.rs +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -40,13 +40,7 @@ impl FlowEditor { pub fn clear_annotations(&mut self, cx: &mut Context) { let originator = self.local_annotation_originator.clone(); - if self.apply_intent( - flowstate_flow::FlowIntent::ClearAnnotations { - sheet_id: None, - originator, - }, - cx, - ) { + if self.apply_intent(flowstate_flow::FlowIntent::ClearAnnotations { sheet_id: None, originator }, cx) { self.changed(self.active_cell, cx); } } @@ -126,7 +120,10 @@ impl FlowEditor { } fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { - let Some(sheet) = self.active_sheet.and_then(|sheet_id| self.board.sheet(sheet_id)) else { + let Some(sheet) = self + .active_sheet + .and_then(|sheet_id| self.board.sheet(sheet_id)) + else { return; }; let radius = 10.0; diff --git a/crates/flowstate/src/flow/editor/telemetry.rs b/crates/flowstate/src/flow/editor/telemetry.rs index 4d992b1d..ffda3815 100644 --- a/crates/flowstate/src/flow/editor/telemetry.rs +++ b/crates/flowstate/src/flow/editor/telemetry.rs @@ -174,7 +174,16 @@ impl FlowEditor { self .board() .cell(id) - .map(|(_, cell)| cell.summary.summary_text.lines().next().unwrap_or_default().trim().to_string()) + .map(|(_, cell)| { + cell + .summary + .summary_text + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string() + }) .filter(|label| !label.is_empty()) .unwrap_or_else(|| format!("cell:{}", &id.to_string()[..8])) } @@ -196,9 +205,7 @@ impl FlowEditor { self .active_sheet .zip(self.dragging_cell) - .is_some_and(|(sheet, dragged)| { - flowstate_flow::board_ops::preview_move_cell_subtree(self.board(), sheet, dragged, intent).is_some() - }) + .is_some_and(|(sheet, dragged)| flowstate_flow::board_ops::preview_move_cell_subtree(self.board(), sheet, dragged, intent).is_some()) } fn sheet_topology_snapshot(&self) -> Vec { diff --git a/crates/flowstate/src/workspace/workspace/collab.rs b/crates/flowstate/src/workspace/workspace/collab.rs index 1da63a1c..91486745 100644 --- a/crates/flowstate/src/workspace/workspace/collab.rs +++ b/crates/flowstate/src/workspace/workspace/collab.rs @@ -353,7 +353,7 @@ impl Workspace { let (panel_id, editor) = match (joined.payload, authority, io) { (JoinedDocumentPayload::RichText(document), JoinedAuthority::RichText(authority), flowstate_collab::SyncIoHandle::RichText(io)) => { let attachment = DocumentRuntimeAttachment { authority, io }; - let panel = match workspace.add_joined_collaboration_panel(document, joined.title, attachment, window, cx) { + let panel = match workspace.add_joined_collaboration_panel(*document, joined.title, attachment, window, cx) { Ok(panel) => panel, Err(error) => { tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 2f98a7d4..994d8e0c 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -1396,9 +1396,16 @@ impl Workspace { return; } let save_task = editor.update(cx, |editor, cx| editor.save(cx)); + let dropbox = self.flow_dropbox_sync_context(&editor, cx); let window_handle = window.window_handle(); cx.spawn(async move |_, cx| { - if let Err(error) = save_task.await { + let result = match save_task.await { + // The bound-path mirror runs after (and only after) a good local + // save, exactly like the .db8 checkpoint hook. + Ok(()) => sync_flow_dropbox_binding(dropbox).await, + Err(error) => Err(error), + }; + if let Err(error) = result { let detail = error.to_string(); let _ = window_handle.update(cx, |_, window, cx| { window.prompt(PromptLevel::Critical, "Save failed", Some(&detail), &[PromptButton::ok("Ok")], cx) @@ -1410,6 +1417,26 @@ impl Workspace { } } + /// Everything the post-save Dropbox mirror needs, captured while the + /// workspace lease is held: the flow's bound path, its document id (the + /// cross-document clobber gate), and the panel's I/O service. + fn flow_dropbox_sync_context( + &self, + editor: &Entity, + cx: &App, + ) -> Option<(PathBuf, Uuid, flowstate_collab::flow::FlowIoHandle)> { + let path = editor.read(cx).document_path()?.clone(); + crate::app_settings::load_dropbox_document_binding(&path)?; + let document_id = editor.read(cx).handle().document_id().ok()?; + let panel_id = self + .flow_panels + .iter() + .find(|panel| panel.read(cx).editor() == *editor) + .map(|panel| panel.read(cx).id())?; + let io = self.flow_document_runtimes.get(&panel_id)?.clone(); + Some((path, document_id, io)) + } + pub fn save_active_as(&mut self, window: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { self.prompt_save_active_as(editor, window, cx); @@ -1522,8 +1549,13 @@ impl Workspace { self.autosave_flow_in_flight.insert(panel_id); let save_task = editor.update(cx, |editor, cx| editor.save(cx)); + let dropbox = self.flow_dropbox_sync_context(&editor, cx); cx.spawn(async move |workspace, cx| { - if let Err(error) = save_task.await { + let result = match save_task.await { + Ok(()) => sync_flow_dropbox_binding(dropbox).await, + Err(error) => Err(error), + }; + if let Err(error) = result { eprintln!("flow autosave failed: {error}"); } let _ = workspace.update(cx, |workspace, _| { @@ -1791,6 +1823,16 @@ fn write_bytes_to_path(path: &Path, bytes: &[u8]) -> std::io::Result<()> { .map_err(Into::into) } +/// Mirror a just-saved, Dropbox-bound flow to its remote path (spec Part C +/// lifecycle parity). `None` context (no path / no binding / no I/O service) +/// is the common case and a silent no-op. +async fn sync_flow_dropbox_binding(context: Option<(PathBuf, Uuid, flowstate_collab::flow::FlowIoHandle)>) -> std::io::Result<()> { + match context { + Some((path, document_id, io)) => crate::collab::dropbox_checkpoint::sync_bound_flow_file(&path, document_id, &io).await, + None => Ok(()), + } +} + /// Stable short tag for a runtime event variant, used only by fidelity firehose /// lines so a document-runtime commit stream reads which transition was applied. enum LoadedWorkspaceDocument { diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs index 12bdc896..92a95062 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs @@ -59,7 +59,9 @@ fn start_and_leave_flow_collaboration_session_round_trip(cx: &mut TestAppContext let h = support::open_workspace(cx); h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); cx.run_until_parked(); - let panel_id = h.read(cx, |ws| ws.active_document_id).expect("flow panel id"); + let panel_id = h + .read(cx, |ws| ws.active_document_id) + .expect("flow panel id"); let session = h.update(cx, |ws, _, cx| ws.start_collaboration_on_active_document(cx)); assert!(session.is_some(), "hosting a session on a fresh flow must succeed"); @@ -95,7 +97,9 @@ fn start_and_leave_flow_collaboration_session_round_trip(cx: &mut TestAppContext // The tab stays editable after leave: a structural intent still commits // through the same gated authority. - let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); let sheets_before = h.update(cx, |_, _, cx| flow.read(cx).board().sheets.len()); h.update(cx, |_, _, cx| flow.update(cx, |editor, cx| editor.create_sheet(cx))); cx.run_until_parked(); @@ -142,7 +146,9 @@ fn flow_join_handoff_pathless_tab_recovery_written_and_discarded(cx: &mut TestAp }); cx.run_until_parked(); - let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("joined flow tab active"); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("joined flow tab active"); h.update(cx, |_, _, cx| { let editor = flow.read(cx); assert!(editor.document_path().is_none(), "joined tab must be pathless (autosave skips it)"); @@ -163,7 +169,8 @@ fn flow_join_handoff_pathless_tab_recovery_written_and_discarded(cx: &mut TestAp h.update(cx, |_, _, cx| { assert_eq!(flow.read(cx).board().sheets.len(), 2, "joined tab edits commit through the gate"); }); - cx.executor().advance_clock(std::time::Duration::from_millis(800)); + cx.executor() + .advance_clock(std::time::Duration::from_millis(800)); let recovery_for_wait = recovery.clone(); h.wait_until(cx, "flow recovery file write", move |_| recovery_for_wait.exists()); let recovered = flowstate_flow::read_fl0(&recovery).expect("recovery file is a valid .fl0 v2"); diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs index 212e4d55..f061fe65 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs @@ -59,7 +59,9 @@ fn flow_external_presence_outline_and_cell_caret_forwarding(cx: &mut TestAppCont let h = support::open_workspace(cx); h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); cx.run_until_parked(); - let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); // One sheet + one cell, typed through the authority, editor open. h.update(cx, |_, _, cx| { @@ -119,7 +121,9 @@ fn flow_external_presence_outline_and_cell_caret_forwarding(cx: &mut TestAppCont h.update(cx, |_, _, cx| { let editor = flow.read(cx); assert_eq!( - editor.presence_for_cell(cell_id).map(|presence| presence.color_rgb), + editor + .presence_for_cell(cell_id) + .map(|presence| presence.color_rgb), Some(0x00ff_0000), "remote focus must outline the focused cell in the peer color", ); @@ -143,7 +147,12 @@ fn flow_external_presence_outline_and_cell_caret_forwarding(cx: &mut TestAppCont let editor = flow.read(cx); assert!(editor.presence_for_cell(cell_id).is_none()); let cell_editor = editor.cell_editor(cell_id).expect("open cell editor"); - assert!(cell_editor.read(cx).external_carets_for_paragraph(0).is_empty()); + assert!( + cell_editor + .read(cx) + .external_carets_for_paragraph(0) + .is_empty() + ); }); } @@ -156,7 +165,9 @@ fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { let h = support::open_workspace(cx); h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); cx.run_until_parked(); - let flow = h.read(cx, |ws| ws.active_flow.clone()).expect("active flow"); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); // Structural edits: sheet + two root cells + typing into the second. h.update(cx, |_, _, cx| { @@ -205,18 +216,24 @@ fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { flow.update(cx, |editor, cx| { let sheet = editor.board().sheets[0].id; editor.activate_sheet(sheet, cx); - let moved = editor.handle().apply(flowstate_flow::FlowIntent::MoveCellSubtree { - sheet_id: sheet, - cell_id: second, - drop: flowstate_flow::FlowDropIntent::AfterSibling(first), - }); + let moved = editor + .handle() + .apply(flowstate_flow::FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: second, + drop: flowstate_flow::FlowDropIntent::AfterSibling(first), + }); assert!(moved.is_ok_and(|outcome| outcome.changed), "drag commit through the gate"); editor.sync_board_from_handle(cx); }); }); cx.run_until_parked(); h.update(cx, |_, _, cx| { - let order: Vec<_> = flow.read(cx).board().sheets[0].cells.iter().map(|cell| cell.id).collect(); + let order: Vec<_> = flow.read(cx).board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect(); assert_eq!(order, vec![first, second]); }); @@ -226,7 +243,11 @@ fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { }); cx.run_until_parked(); h.update(cx, |_, _, cx| { - let order: Vec<_> = flow.read(cx).board().sheets[0].cells.iter().map(|cell| cell.id).collect(); + let order: Vec<_> = flow.read(cx).board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect(); assert_eq!(order, vec![second, first], "undo reversed the move"); }); @@ -235,12 +256,14 @@ fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("gate.fl0"); h.update(cx, |_, _, cx| { - flow.update(cx, |editor, cx| editor.save_as(path.clone(), cx)).detach(); + flow + .update(cx, |editor, cx| editor.save_as(path.clone(), cx)) + .detach(); }); let flow_for_wait = flow.clone(); h.wait_until(cx, "flow save to complete", move |_| path.exists()); cx.run_until_parked(); - let snapshot = flowstate_flow::read_fl0(&dir.join("gate.fl0")).expect("read .fl0 v2"); + let snapshot = flowstate_flow::read_fl0(dir.join("gate.fl0")).expect("read .fl0 v2"); let reloaded = flowstate_collab::flow::FlowRuntime::from_snapshot(&snapshot).expect("reload"); h.update(cx, |_, _, cx| { assert_eq!(reloaded.board_ref(), flow_for_wait.read(cx).board(), "saved board round-trips");