Reduce leaf fail hook actions

This commit is contained in:
Michael Mikovsky
2026-05-09 13:53:49 -06:00
parent 71d1aee235
commit da9166daf0
5 changed files with 395 additions and 39 deletions
+8 -9
View File
@@ -329,10 +329,9 @@ connection closes or unregisters
## Known Gaps In The Current Branch ## Known Gaps In The Current Branch
- `LeafAction::SendCall` and `LeafAction::SendHookData` are reduced by - `LeafAction::SendCall`, `LeafAction::SendHookData`, and `LeafAction::FailHook`
`NodeRuntime`; hook fault and connection action variants are still unsupported are reduced by `NodeRuntime`; connection action variants are still unsupported
and must remain queued when encountered. and must remain queued when encountered.
- Hook fault actions through the runtime are not implemented.
- Connection actions through the runtime are not implemented. - Connection actions through the runtime are not implemented.
- Disconnect does not yet clean hooks, sessions, route state, and queued effects. - Disconnect does not yet clean hooks, sessions, route state, and queued effects.
- Child ingress still allocates because the existing `Ingress::Child` owns a - Child ingress still allocates because the existing `Ingress::Child` owns a
@@ -340,13 +339,13 @@ connection closes or unregisters
## Next Implementation Slice ## Next Implementation Slice
Implement the next narrow leaf-action path: Implement the next narrow connection-action path:
1. Apply queued `LeafAction::FailHook` through endpoint packet state. 1. Keep connection registration actions queued until runtime-owned disconnect
2. Preserve pending/active hook cleanup semantics without dropping unprocessed
actions.
3. Keep connection registration actions queued until runtime-owned disconnect
cleanup can update connections, routes, hooks, and queued effects atomically. cleanup can update connections, routes, hooks, and queued effects atomically.
2. Add connection registration reduction only when route, connection, hook, and
queued-effect cleanup can be updated as one runtime transaction.
3. Preserve FIFO retry semantics for unsupported or failed connection actions.
That slice should continue the one-variant-at-a-time reducer approach without That slice should continue the one-variant-at-a-time reducer approach without
implementing connection actions early. implementing disconnect cleanup early.
@@ -10,6 +10,31 @@ use super::super::{HookKey, RouteDecision};
use super::core::{EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint}; use super::core::{EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint};
impl ProtocolEndpoint { impl ProtocolEndpoint {
/// Returns the route that would carry a locally generated hook fault for `hook_id`.
///
/// The method does not mutate hook state. Runtime owners use it to preflight transport
/// availability before calling [`fail_hook`](Self::fail_hook), which removes hook state when
/// the fault is emitted.
#[must_use]
pub fn hook_fault_route(&self, hook_id: u64) -> Option<RouteDecision> {
self.hooks
.key_for_hook_id(hook_id)
.map(|key| self.decide_route(&key.return_path))
}
/// Terminates a locally known hook with a protocol fault.
///
/// Unknown hooks are treated as an intentional drop. Known hooks are removed before the fault
/// is routed so no further local data can be emitted after the terminal fault.
pub fn fail_hook(
&mut self,
hook_id: u64,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
let key = self.hooks.key_for_hook_id(hook_id);
self.emit_fault_if_possible(key, fault)
}
pub(crate) fn emit_fault_if_possible( pub(crate) fn emit_fault_if_possible(
&mut self, &mut self,
key: Option<HookKey>, key: Option<HookKey>,
@@ -324,6 +324,25 @@ impl HookTable {
Some(active) Some(active)
} }
/// Returns a hook key matching `hook_id`, preferring active hooks over pending hooks.
///
/// This is intentionally a narrow bridge for current leaf APIs that identify a hook only by
/// id. Hook ids are protocol-scoped by host path, so future APIs should pass the full
/// [`HookKey`] when leaf dispatch exposes it.
#[must_use]
pub fn key_for_hook_id(&self, hook_id: u64) -> Option<HookKey> {
self.active
.keys()
.find(|key| key.hook_id == hook_id)
.cloned()
.or_else(|| {
self.pending
.keys()
.find(|key| key.hook_id == hook_id)
.cloned()
})
}
/// Returns the pending hook for `key`, if present. /// Returns the pending hook for `key`, if present.
/// ///
/// # Example /// # Example
+17 -2
View File
@@ -6,8 +6,8 @@
//! handles, does not dispatch leaves, and does not make admission decisions. //! handles, does not dispatch leaves, and does not make admission decisions.
use unshell_protocol::{ use unshell_protocol::{
CallMessage, FrameBytes, PacketHeader, PacketType, tree::Endpoint as ProtocolEndpointTrait, CallMessage, FrameBytes, PacketHeader, PacketType, ProtocolFault,
validate_call, validate_header, validate_procedure_id, tree::Endpoint as ProtocolEndpointTrait, validate_call, validate_header, validate_procedure_id,
}; };
pub use unshell_protocol::tree::{ pub use unshell_protocol::tree::{
@@ -90,6 +90,21 @@ impl EndpointState {
.send_data(dst_path, hook_id, procedure_id, data, end_hook) .send_data(dst_path, hook_id, procedure_id, data, end_hook)
} }
/// Returns the route that would carry a terminal hook fault, if the hook is known.
#[must_use]
pub fn hook_fault_route(&self, hook_id: u64) -> Option<RouteDecision> {
self.endpoint.hook_fault_route(hook_id)
}
/// Terminates a known hook with a protocol fault, or drops unknown hook ids.
pub fn fail_hook(
&mut self,
hook_id: u64,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
self.endpoint.fail_hook(hook_id, fault)
}
/// Builds and routes one call packet through the wrapped endpoint state. /// Builds and routes one call packet through the wrapped endpoint state.
pub fn send_call( pub fn send_call(
&mut self, &mut self,
+326 -28
View File
@@ -3,8 +3,7 @@
//! This first slice owns transport and connection metadata, derives ingress from //! This first slice owns transport and connection metadata, derives ingress from
//! registered connections, delegates packet invariants to [`EndpointState`], and //! registered connections, delegates packet invariants to [`EndpointState`], and
//! queues concrete runtime effects. Leaf action reduction is intentionally //! queues concrete runtime effects. Leaf action reduction is intentionally
//! narrow: this slice only turns outbound calls and hook-data replies into //! narrow and grows one action family at a time.
//! endpoint outcomes.
use crate::alloc::{string::String, vec::Vec}; use crate::alloc::{string::String, vec::Vec};
use crate::connections::{ use crate::connections::{
@@ -544,10 +543,10 @@ where
/// Reduces queued leaf actions through endpoint packet state. /// Reduces queued leaf actions through endpoint packet state.
/// ///
/// [`LeafAction::SendCall`] and [`LeafAction::SendHookData`] are implemented /// [`LeafAction::SendCall`], [`LeafAction::SendHookData`], and
/// in this slice. Unsupported actions stop reduction and remain queued with /// [`LeafAction::FailHook`] are implemented in this slice. Unsupported
/// all later actions so callers can retry after a future runtime gains /// actions stop reduction and remain queued with all later actions so callers
/// support. /// can retry after a future runtime gains support.
pub fn reduce_leaf_actions(&mut self) -> Result<usize, NodeRuntimeError<T::Error>> { pub fn reduce_leaf_actions(&mut self) -> Result<usize, NodeRuntimeError<T::Error>> {
let mut reduced = 0usize; let mut reduced = 0usize;
let mut retained = Vec::new(); let mut retained = Vec::new();
@@ -649,6 +648,40 @@ where
} }
reduced += 1; reduced += 1;
} }
LeafAction::FailHook { hook_id, fault } => {
let original_action = LeafAction::FailHook { hook_id, fault };
if let Some(route) = self.endpoint.hook_fault_route(hook_id)
&& (matches!(route, RouteDecision::Drop)
|| (route_requires_connection(route)
&& self.connection_for_route(route).is_none()))
{
retained.push((leaf_id, original_action));
retained.extend(pending);
self.leaf_actions = retained;
return Err(NodeRuntimeError::MissingRouteConnection);
}
let endpoint_checkpoint = self.endpoint.clone();
let outcome = match self.endpoint.fail_hook(hook_id, fault) {
Ok(outcome) => outcome,
Err(error) => {
self.endpoint = endpoint_checkpoint;
retained.push((leaf_id, original_action));
retained.extend(pending);
self.leaf_actions = retained;
return Err(NodeRuntimeError::Endpoint(error));
}
};
if let Err(error) = self.apply_outcome(outcome) {
self.endpoint = endpoint_checkpoint;
retained.push((leaf_id, original_action));
retained.extend(pending);
self.leaf_actions = retained;
return Err(error);
}
reduced += 1;
}
unsupported => { unsupported => {
let action_name = leaf_action_name(&unsupported); let action_name = leaf_action_name(&unsupported);
retained.push((leaf_id.clone(), unsupported)); retained.push((leaf_id.clone(), unsupported));
@@ -825,6 +858,7 @@ mod tests {
use crate::transport::Transport; use crate::transport::Transport;
use unshell_protocol::tree::{ use unshell_protocol::tree::{
ChildRoute, EndpointError, IncomingCall, LeafSpec, LocalEvent, ProtocolEndpoint, ChildRoute, EndpointError, IncomingCall, LeafSpec, LocalEvent, ProtocolEndpoint,
RouteDecision,
}; };
use unshell_protocol::{ use unshell_protocol::{
CallMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ProtocolFault, decode_frame, CallMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ProtocolFault, decode_frame,
@@ -1536,6 +1570,82 @@ mod tests {
assert!(data.end_hook); assert!(data.end_hook);
} }
#[test]
fn leaf_fail_hook_reduces_to_parent_fault_frame() {
let parent = ConnectionId::new(1);
let mut connections = Connections::new();
connections.push(Connection::registered(
parent,
ConnectionDirection::Parent,
vec![],
ConnectionGeneration::INITIAL,
));
let leaf_name = "org.example.v1.echo";
let endpoint = ProtocolEndpoint::new(
vec![String::from("agent")],
Some(vec![]),
vec![],
vec![LeafSpec {
name: String::from(leaf_name),
procedures: vec![String::from("org.example.v1.echo.invoke")],
}],
);
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: vec![],
dst_path: vec![String::from("agent")],
dst_leaf: Some(String::from(leaf_name)),
hook_id: None,
},
&CallMessage {
procedure_id: String::from("org.example.v1.echo.invoke"),
data: vec![9],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: vec![],
}),
},
)
.expect("frame encodes");
let calls = Rc::new(RefCell::new(Vec::new()));
let mut runtime = NodeRuntime::new(
EndpointState::new(endpoint),
connections,
RecordingTransport::default(),
);
runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls)));
runtime
.receive_frame(parent, frame)
.expect("call activates hook");
runtime.dispatch_local_effects().expect("dispatch succeeds");
runtime.leaf_actions.clear();
runtime.leaf_actions.push((
crate::leaf::LeafId::new(String::from(leaf_name)),
LeafAction::FailHook {
hook_id: 7,
fault: ProtocolFault::INTERNAL_ERROR,
},
));
let reduced = runtime.reduce_leaf_actions().expect("fault reduces");
let outcome = runtime.tick(TickBudget::default()).expect("tick flushes");
assert_eq!(reduced, 1);
assert!(runtime.leaf_actions().is_empty());
assert_eq!(outcome.outbound_frames, 1);
assert_eq!(runtime.transport().sent.len(), 1);
assert_eq!(runtime.transport().sent[0].0, parent);
let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes");
assert_eq!(parsed.header().packet_type, PacketType::Fault);
assert_eq!(parsed.header().src_path, [String::from("agent")]);
assert_eq!(parsed.header().dst_path, Vec::<String>::new());
assert_eq!(parsed.header().hook_id, Some(7));
let fault = parsed.deserialize_fault().expect("payload is fault");
assert_eq!(fault.fault, ProtocolFault::INTERNAL_ERROR);
}
#[test] #[test]
fn leaf_send_call_reduces_to_child_transport_frame() { fn leaf_send_call_reduces_to_child_transport_frame() {
let child = ConnectionId::new(1); let child = ConnectionId::new(1);
@@ -1810,7 +1920,7 @@ mod tests {
} }
#[test] #[test]
fn unsupported_leaf_action_is_reported_and_retained() { fn unsupported_connection_action_is_reported_and_retained() {
let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.echo")); let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.echo"));
let mut runtime = NodeRuntime::new( let mut runtime = NodeRuntime::new(
EndpointState::new(ProtocolEndpoint::new( EndpointState::new(ProtocolEndpoint::new(
@@ -1822,13 +1932,6 @@ mod tests {
Connections::new(), Connections::new(),
RecordingTransport::default(), RecordingTransport::default(),
); );
runtime.leaf_actions.push((
leaf_id.clone(),
LeafAction::FailHook {
hook_id: 7,
fault: ProtocolFault::INTERNAL_ERROR,
},
));
runtime.leaf_actions.push(( runtime.leaf_actions.push((
leaf_id.clone(), leaf_id.clone(),
LeafAction::Connection(ConnectionAction::Unregister { LeafAction::Connection(ConnectionAction::Unregister {
@@ -1843,15 +1946,11 @@ mod tests {
assert!(matches!( assert!(matches!(
error, error,
NodeRuntimeError::UnsupportedLeafAction { ref leaf_id, action } NodeRuntimeError::UnsupportedLeafAction { ref leaf_id, action }
if leaf_id.as_str() == "org.example.v1.echo" && action == "FailHook" if leaf_id.as_str() == "org.example.v1.echo" && action == "Connection"
)); ));
assert_eq!(runtime.leaf_actions().len(), 2); assert_eq!(runtime.leaf_actions().len(), 1);
assert!(matches!( assert!(matches!(
runtime.leaf_actions()[0].1, runtime.leaf_actions()[0].1,
LeafAction::FailHook { .. }
));
assert!(matches!(
runtime.leaf_actions()[1].1,
LeafAction::Connection(_) LeafAction::Connection(_)
)); ));
} }
@@ -1939,26 +2038,225 @@ mod tests {
runtime runtime
.register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL)
.expect("parent route restored"); .expect("parent route restored");
let retry_error = runtime let reduced = runtime
.reduce_leaf_actions() .reduce_leaf_actions()
.expect_err("later unsupported action is still reported"); .expect("remaining supported actions reduce");
assert_eq!(reduced, 2);
assert!(runtime.leaf_actions().is_empty());
assert!(matches!(
runtime.effects()[0],
RuntimeEffect::SendFrame { connection, .. } if connection == parent
));
assert!(matches!(
runtime.effects()[1],
RuntimeEffect::SendFrame { connection, .. } if connection == parent
));
}
#[test]
fn missing_fail_hook_route_preserves_action_and_hook_for_retry() {
let parent = ConnectionId::new(1);
let mut connections = Connections::new();
connections.push(Connection::registered(
parent,
ConnectionDirection::Parent,
vec![],
ConnectionGeneration::INITIAL,
));
let leaf_name = "org.example.v1.echo";
let endpoint = ProtocolEndpoint::new(
vec![String::from("agent")],
Some(vec![]),
vec![],
vec![LeafSpec {
name: String::from(leaf_name),
procedures: vec![String::from("org.example.v1.echo.invoke")],
}],
);
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: vec![],
dst_path: vec![String::from("agent")],
dst_leaf: Some(String::from(leaf_name)),
hook_id: None,
},
&CallMessage {
procedure_id: String::from("org.example.v1.echo.invoke"),
data: vec![],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: vec![],
}),
},
)
.expect("frame encodes");
let calls = Rc::new(RefCell::new(Vec::new()));
let mut runtime = NodeRuntime::new(
EndpointState::new(endpoint),
connections,
RecordingTransport::default(),
);
runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls)));
runtime
.receive_frame(parent, frame)
.expect("call activates hook");
runtime.dispatch_local_effects().expect("dispatch succeeds");
runtime.leaf_actions.clear();
runtime.leaf_actions.push((
crate::leaf::LeafId::new(String::from(leaf_name)),
LeafAction::FailHook {
hook_id: 7,
fault: ProtocolFault::INTERNAL_ERROR,
},
));
runtime.leaf_actions.push((
crate::leaf::LeafId::new(String::from(leaf_name)),
LeafAction::Connection(ConnectionAction::Unregister { connection: parent }),
));
runtime
.connections
.get_mut(parent)
.expect("parent connection exists")
.set_state(ConnectionState::Connected {
generation: ConnectionGeneration::INITIAL,
});
let error = runtime
.reduce_leaf_actions()
.expect_err("missing route connection is reported");
assert!(matches!(error, NodeRuntimeError::MissingRouteConnection));
assert_eq!(runtime.leaf_actions().len(), 2);
assert!(matches!(
runtime.leaf_actions()[0].1,
LeafAction::FailHook { .. }
));
assert!(matches!(
runtime.leaf_actions()[1].1,
LeafAction::Connection(_)
));
assert!(runtime.effects().is_empty());
runtime
.register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL)
.expect("parent route restored");
let error = runtime
.reduce_leaf_actions()
.expect_err("retry faults hook then stops at connection action");
let outcome = runtime.tick(TickBudget::default()).expect("tick flushes");
assert!(matches!( assert!(matches!(
retry_error, error,
NodeRuntimeError::UnsupportedLeafAction { NodeRuntimeError::UnsupportedLeafAction {
action: "FailHook", action: "Connection",
.. ..
} }
)); ));
assert_eq!(runtime.leaf_actions().len(), 1); assert_eq!(runtime.leaf_actions().len(), 1);
assert!(matches!( assert!(matches!(
runtime.leaf_actions()[0].1, runtime.leaf_actions()[0].1,
LeafAction::FailHook { .. } LeafAction::Connection(_)
)); ));
assert!(matches!( assert_eq!(outcome.outbound_frames, 1);
runtime.effects()[0], let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes");
RuntimeEffect::SendFrame { connection, .. } if connection == parent assert_eq!(parsed.header().packet_type, PacketType::Fault);
assert_eq!(parsed.header().hook_id, Some(7));
}
#[test]
fn dropped_fail_hook_route_preserves_action_and_hook_for_retry() {
let parent = ConnectionId::new(1);
let mut connections = Connections::new();
connections.push(Connection::registered(
parent,
ConnectionDirection::Parent,
vec![],
ConnectionGeneration::INITIAL,
)); ));
let leaf_name = "org.example.v1.echo";
let endpoint = ProtocolEndpoint::new(
vec![String::from("agent")],
Some(vec![]),
vec![],
vec![LeafSpec {
name: String::from(leaf_name),
procedures: vec![String::from("org.example.v1.echo.invoke")],
}],
);
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: vec![],
dst_path: vec![String::from("agent")],
dst_leaf: Some(String::from(leaf_name)),
hook_id: None,
},
&CallMessage {
procedure_id: String::from("org.example.v1.echo.invoke"),
data: vec![],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: vec![],
}),
},
)
.expect("frame encodes");
let calls = Rc::new(RefCell::new(Vec::new()));
let mut runtime = NodeRuntime::new(
EndpointState::new(endpoint),
connections,
RecordingTransport::default(),
);
runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls)));
runtime
.receive_frame(parent, frame)
.expect("call activates hook with dropped return path");
assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_)));
runtime.dispatch_local_effects().expect("dispatch succeeds");
runtime
.endpoint
.endpoint_mut()
.set_parent_path(None)
.expect("parent route removes");
assert_eq!(
runtime.endpoint.hook_fault_route(7),
Some(RouteDecision::Drop)
);
runtime.leaf_actions.clear();
runtime.leaf_actions.push((
crate::leaf::LeafId::new(String::from(leaf_name)),
LeafAction::FailHook {
hook_id: 7,
fault: ProtocolFault::INTERNAL_ERROR,
},
));
let error = runtime
.reduce_leaf_actions()
.expect_err("dropped fault route is reported before mutation");
assert!(matches!(error, NodeRuntimeError::MissingRouteConnection));
assert_eq!(runtime.leaf_actions().len(), 1);
assert!(runtime.effects().is_empty());
runtime
.register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL)
.expect("parent route restored");
let reduced = runtime
.reduce_leaf_actions()
.expect("retained fault retries after route is restored");
let outcome = runtime.tick(TickBudget::default()).expect("tick flushes");
assert_eq!(reduced, 1);
assert_eq!(outcome.outbound_frames, 1);
assert_eq!(runtime.transport().sent[0].0, parent);
let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes");
assert_eq!(parsed.header().packet_type, PacketType::Fault);
assert_eq!(parsed.header().hook_id, Some(7));
} }
#[test] #[test]