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
- `LeafAction::SendCall` and `LeafAction::SendHookData` are reduced by
`NodeRuntime`; hook fault and connection action variants are still unsupported
- `LeafAction::SendCall`, `LeafAction::SendHookData`, and `LeafAction::FailHook`
are reduced by `NodeRuntime`; connection action variants are still unsupported
and must remain queued when encountered.
- Hook fault 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.
- Child ingress still allocates because the existing `Ingress::Child` owns a
@@ -340,13 +339,13 @@ connection closes or unregisters
## 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.
2. Preserve pending/active hook cleanup semantics without dropping unprocessed
actions.
3. Keep connection registration actions queued until runtime-owned disconnect
1. Keep connection registration actions queued until runtime-owned disconnect
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
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};
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(
&mut self,
key: Option<HookKey>,
@@ -324,6 +324,25 @@ impl HookTable {
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.
///
/// # Example
+17 -2
View File
@@ -6,8 +6,8 @@
//! handles, does not dispatch leaves, and does not make admission decisions.
use unshell_protocol::{
CallMessage, FrameBytes, PacketHeader, PacketType, tree::Endpoint as ProtocolEndpointTrait,
validate_call, validate_header, validate_procedure_id,
CallMessage, FrameBytes, PacketHeader, PacketType, ProtocolFault,
tree::Endpoint as ProtocolEndpointTrait, validate_call, validate_header, validate_procedure_id,
};
pub use unshell_protocol::tree::{
@@ -90,6 +90,21 @@ impl EndpointState {
.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.
pub fn send_call(
&mut self,
+326 -28
View File
@@ -3,8 +3,7 @@
//! This first slice owns transport and connection metadata, derives ingress from
//! registered connections, delegates packet invariants to [`EndpointState`], and
//! queues concrete runtime effects. Leaf action reduction is intentionally
//! narrow: this slice only turns outbound calls and hook-data replies into
//! endpoint outcomes.
//! narrow and grows one action family at a time.
use crate::alloc::{string::String, vec::Vec};
use crate::connections::{
@@ -544,10 +543,10 @@ where
/// Reduces queued leaf actions through endpoint packet state.
///
/// [`LeafAction::SendCall`] and [`LeafAction::SendHookData`] are implemented
/// in this slice. Unsupported actions stop reduction and remain queued with
/// all later actions so callers can retry after a future runtime gains
/// support.
/// [`LeafAction::SendCall`], [`LeafAction::SendHookData`], and
/// [`LeafAction::FailHook`] are implemented in this slice. Unsupported
/// actions stop reduction and remain queued with all later actions so callers
/// can retry after a future runtime gains support.
pub fn reduce_leaf_actions(&mut self) -> Result<usize, NodeRuntimeError<T::Error>> {
let mut reduced = 0usize;
let mut retained = Vec::new();
@@ -649,6 +648,40 @@ where
}
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 => {
let action_name = leaf_action_name(&unsupported);
retained.push((leaf_id.clone(), unsupported));
@@ -825,6 +858,7 @@ mod tests {
use crate::transport::Transport;
use unshell_protocol::tree::{
ChildRoute, EndpointError, IncomingCall, LeafSpec, LocalEvent, ProtocolEndpoint,
RouteDecision,
};
use unshell_protocol::{
CallMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ProtocolFault, decode_frame,
@@ -1536,6 +1570,82 @@ mod tests {
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]
fn leaf_send_call_reduces_to_child_transport_frame() {
let child = ConnectionId::new(1);
@@ -1810,7 +1920,7 @@ mod tests {
}
#[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 mut runtime = NodeRuntime::new(
EndpointState::new(ProtocolEndpoint::new(
@@ -1822,13 +1932,6 @@ mod tests {
Connections::new(),
RecordingTransport::default(),
);
runtime.leaf_actions.push((
leaf_id.clone(),
LeafAction::FailHook {
hook_id: 7,
fault: ProtocolFault::INTERNAL_ERROR,
},
));
runtime.leaf_actions.push((
leaf_id.clone(),
LeafAction::Connection(ConnectionAction::Unregister {
@@ -1843,15 +1946,11 @@ mod tests {
assert!(matches!(
error,
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!(
runtime.leaf_actions()[0].1,
LeafAction::FailHook { .. }
));
assert!(matches!(
runtime.leaf_actions()[1].1,
LeafAction::Connection(_)
));
}
@@ -1939,26 +2038,225 @@ mod tests {
runtime
.register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL)
.expect("parent route restored");
let retry_error = runtime
let reduced = runtime
.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!(
retry_error,
error,
NodeRuntimeError::UnsupportedLeafAction {
action: "FailHook",
action: "Connection",
..
}
));
assert_eq!(runtime.leaf_actions().len(), 1);
assert!(matches!(
runtime.leaf_actions()[0].1,
LeafAction::FailHook { .. }
LeafAction::Connection(_)
));
assert!(matches!(
runtime.effects()[0],
RuntimeEffect::SendFrame { connection, .. } if connection == parent
assert_eq!(outcome.outbound_frames, 1);
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]
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]