mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Merge pull request #3 from Astatin3/redesign-runtime-protocol
Redesign runtime protocol
This commit is contained in:
@@ -0,0 +1,351 @@
|
|||||||
|
# UnShell Runtime API Proposal
|
||||||
|
|
||||||
|
This document records the proposed public API direction for the runtime redesign.
|
||||||
|
The goal is to split packet processing from node orchestration while keeping the
|
||||||
|
implant-facing runtime single-threaded, explicit, and hard to misuse.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep `unshell-protocol` focused on packet types, framing, encoding, decoding,
|
||||||
|
and static validation.
|
||||||
|
- Move endpoint state, routing state, hook state, connection admission, transport
|
||||||
|
ownership, leaf dispatch, and scheduling into `unshell-runtime`.
|
||||||
|
- Run without internal threads. Progress happens only when the caller drives the
|
||||||
|
runtime with `tick` or explicit local actions.
|
||||||
|
- Let every leaf request calls, hook data, faults, and connection changes without
|
||||||
|
giving leaves direct access to routes, hooks, endpoint internals, or transports.
|
||||||
|
- Preserve protocol authority rules by deriving ingress from registered connection
|
||||||
|
metadata, never from caller-provided values.
|
||||||
|
- Keep hot packet paths allocation-aware and move toward borrowed packet/event
|
||||||
|
views where the current protocol API permits it.
|
||||||
|
|
||||||
|
## Crate Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
unshell-protocol
|
||||||
|
PacketHeader, CallMessage, DataMessage, FaultMessage
|
||||||
|
encode_packet, decode_frame
|
||||||
|
validate_header, validate_call, validate_procedure_id
|
||||||
|
introspection payload schemas
|
||||||
|
|
||||||
|
unshell-runtime
|
||||||
|
EndpointState
|
||||||
|
NodeRuntime
|
||||||
|
Connections
|
||||||
|
Transport
|
||||||
|
Leaf, LeafContext, LeafAction
|
||||||
|
runtime effects and scheduling
|
||||||
|
|
||||||
|
unshell
|
||||||
|
facade re-exports: protocol, runtime, leaves, macros
|
||||||
|
```
|
||||||
|
|
||||||
|
`EndpointState` is transitional. Today it wraps the existing
|
||||||
|
`ProtocolEndpoint`. Long term, the endpoint state machine should live in
|
||||||
|
`unshell-runtime`, while `unshell-protocol` becomes packet-only.
|
||||||
|
|
||||||
|
## Transport API
|
||||||
|
|
||||||
|
Transports move already-framed protocol packets. They do not know paths, leaves,
|
||||||
|
hooks, routing, or admission policy.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Transport {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn poll_recv(&mut self) -> Result<Option<(ConnectionId, FrameBytes)>, Self::Error>;
|
||||||
|
|
||||||
|
fn send_frame(
|
||||||
|
&mut self,
|
||||||
|
connection: ConnectionId,
|
||||||
|
frame: &FrameBytes,
|
||||||
|
) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `poll_recv` must not block.
|
||||||
|
- `ConnectionId` is a runtime handle, not a protocol path.
|
||||||
|
- The runtime maps `ConnectionId` to protocol ingress.
|
||||||
|
|
||||||
|
## Connection API
|
||||||
|
|
||||||
|
Connections are not routable until registered.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ConnectionId(u64);
|
||||||
|
pub struct ConnectionGeneration(u64);
|
||||||
|
|
||||||
|
pub enum ConnectionDirection {
|
||||||
|
Parent,
|
||||||
|
Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegisteredConnection {
|
||||||
|
direction: ConnectionDirection,
|
||||||
|
peer_path: Vec<String>,
|
||||||
|
generation: ConnectionGeneration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ConnectionState {
|
||||||
|
Connected { generation: ConnectionGeneration },
|
||||||
|
Authenticating { generation: ConnectionGeneration },
|
||||||
|
Registered(RegisteredConnection),
|
||||||
|
Draining { generation: ConnectionGeneration },
|
||||||
|
Closed { generation: ConnectionGeneration },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Only `Registered` connections can produce protocol ingress or receive routed
|
||||||
|
frames.
|
||||||
|
- Parent registration must be exactly the direct parent path.
|
||||||
|
- Child registration must be exactly one segment below the local path.
|
||||||
|
- Registering or unregistering a connection must update connection state,
|
||||||
|
endpoint routes, hook cleanup, and queued generation checks atomically.
|
||||||
|
- Queued outbound frames carry `ConnectionGeneration`; stale sends are dropped
|
||||||
|
when a connection slot is reused.
|
||||||
|
|
||||||
|
## Runtime API
|
||||||
|
|
||||||
|
`NodeRuntime` owns endpoint packet state, connections, transport, and queued
|
||||||
|
effects.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct NodeRuntime<T, LeafError = core::convert::Infallible> {
|
||||||
|
endpoint: EndpointState,
|
||||||
|
connections: Connections,
|
||||||
|
transport: T,
|
||||||
|
effects: EffectQueue,
|
||||||
|
leaves: Vec<RegisteredLeaf<LeafError>>,
|
||||||
|
leaf_actions: Vec<(LeafId, LeafAction)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TickBudget {
|
||||||
|
pub max_inbound_frames: usize,
|
||||||
|
pub flush_outbound: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TickOutcome {
|
||||||
|
pub inbound_frames: usize,
|
||||||
|
pub outbound_frames: usize,
|
||||||
|
pub dropped_frames: usize,
|
||||||
|
pub local_events: usize,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Primary operations:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<T: Transport> NodeRuntime<T> {
|
||||||
|
pub fn tick(&mut self, budget: TickBudget) -> Result<TickOutcome, NodeRuntimeError<T::Error>>;
|
||||||
|
|
||||||
|
pub fn receive_frame(
|
||||||
|
&mut self,
|
||||||
|
connection: ConnectionId,
|
||||||
|
frame: FrameBytes,
|
||||||
|
) -> Result<(), NodeRuntimeError<T::Error>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, LeafError> NodeRuntime<T, LeafError> {
|
||||||
|
pub fn new_with_leaf_error(
|
||||||
|
endpoint: EndpointState,
|
||||||
|
connections: Connections,
|
||||||
|
transport: T,
|
||||||
|
) -> Self;
|
||||||
|
|
||||||
|
pub fn drain_local_effects(&mut self) -> impl Iterator<Item = RuntimeEffect>;
|
||||||
|
|
||||||
|
pub fn register_leaf<L>(&mut self, leaf: L) -> LeafId
|
||||||
|
where
|
||||||
|
L: Leaf<Error = LeafError> + 'static;
|
||||||
|
|
||||||
|
pub fn dispatch_local_effects(&mut self) -> Result<usize, LeafDispatchError<LeafError>>;
|
||||||
|
|
||||||
|
pub fn reduce_leaf_actions(&mut self) -> Result<usize, NodeRuntimeError<T::Error>>
|
||||||
|
where
|
||||||
|
T: Transport;
|
||||||
|
|
||||||
|
pub fn drain_leaf_actions(&mut self) -> impl Iterator<Item = (LeafId, LeafAction)>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> NodeRuntime<T> {
|
||||||
|
pub fn register_parent_connection(
|
||||||
|
&mut self,
|
||||||
|
connection: ConnectionId,
|
||||||
|
parent_path: Vec<String>,
|
||||||
|
generation: ConnectionGeneration,
|
||||||
|
) -> Result<(), EndpointError>;
|
||||||
|
|
||||||
|
pub fn register_child_connection(
|
||||||
|
&mut self,
|
||||||
|
connection: ConnectionId,
|
||||||
|
child_path: Vec<String>,
|
||||||
|
generation: ConnectionGeneration,
|
||||||
|
) -> Result<(), EndpointError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
transport poll -> (ConnectionId, FrameBytes)
|
||||||
|
-> look up registered connection
|
||||||
|
-> derive Ingress from registered direction/path
|
||||||
|
-> EndpointState::process_frame
|
||||||
|
-> RuntimeEffect::SendFrame | RuntimeEffect::Local | RuntimeEffect::Dropped
|
||||||
|
-> flush SendFrame effects through Transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Callers never pass `Ingress` into `NodeRuntime`.
|
||||||
|
- Callers should register parent and child connections through `NodeRuntime` so
|
||||||
|
route topology and connection metadata are mutated together. Directly changing
|
||||||
|
only `Connections` or only `EndpointState` can leave a connected peer
|
||||||
|
unroutable or a route without a registered connection.
|
||||||
|
- Runtime counts per-tick progress, not retained backlog.
|
||||||
|
- Local events should be dispatched to leaves, not retained forever.
|
||||||
|
- `dispatch_local_effects` attempts queued `RuntimeEffect::Local` values in FIFO
|
||||||
|
order, calls the matching leaf callback, records queued `LeafAction` values for
|
||||||
|
later reducer work, and leaves unmatched locals queued for a future attempt.
|
||||||
|
- Dispatch does not consume `SendFrame` or `Dropped` effects. Outbound sends remain
|
||||||
|
runtime-owned, and drop notifications remain available to callers that drain
|
||||||
|
local/drop effects.
|
||||||
|
- Send failures must not drop unrelated queued effects.
|
||||||
|
|
||||||
|
## Leaf API
|
||||||
|
|
||||||
|
Leaves are request-only. They can ask the runtime to do work, but cannot mutate
|
||||||
|
endpoint state, hooks, route tables, connection maps, or transports.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Leaf {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn capabilities(&self) -> &LeafCapabilities;
|
||||||
|
|
||||||
|
fn on_call(&mut self, ctx: &mut LeafContext<'_>, call: IncomingCall)
|
||||||
|
-> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
fn on_data(&mut self, ctx: &mut LeafContext<'_>, data: IncomingData)
|
||||||
|
-> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
fn on_fault(&mut self, ctx: &mut LeafContext<'_>, fault: IncomingFault)
|
||||||
|
-> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
fn poll(&mut self, ctx: &mut LeafContext<'_>) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leaf permissions:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct LeafPermissions {
|
||||||
|
pub send_calls: bool,
|
||||||
|
pub send_hook_data: bool,
|
||||||
|
pub manage_connections: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leaf actions:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum LeafAction {
|
||||||
|
SendCall(OutboundCall),
|
||||||
|
SendHookData(OutboundHookData),
|
||||||
|
FailHook { hook_id: u64, fault: ProtocolFault },
|
||||||
|
Connection(ConnectionAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ConnectionAction {
|
||||||
|
Register {
|
||||||
|
connection: ConnectionId,
|
||||||
|
direction: ConnectionDirection,
|
||||||
|
peer_path: Vec<String>,
|
||||||
|
},
|
||||||
|
Unregister { connection: ConnectionId },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- A leaf may queue only actions allowed by its `LeafPermissions`.
|
||||||
|
- Runtime policy still validates every action. Permission is not authority.
|
||||||
|
- Connection actions request runtime changes. They do not mutate state directly.
|
||||||
|
- Leaf callbacks must be bounded and nonblocking.
|
||||||
|
- No nested leaf dispatch. Leaf actions are applied after the callback returns.
|
||||||
|
|
||||||
|
## Required Runtime Semantics
|
||||||
|
|
||||||
|
### Inbound Forwarding
|
||||||
|
|
||||||
|
```text
|
||||||
|
parent frame for /agent/grand
|
||||||
|
-> NodeRuntime derives Ingress::Parent
|
||||||
|
-> EndpointState routes to child /agent/grand
|
||||||
|
-> RuntimeEffect::SendFrame { connection: grandchild, generation, frame }
|
||||||
|
-> Transport::send_frame(grandchild, frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Call Delivery
|
||||||
|
|
||||||
|
```text
|
||||||
|
parent frame for local endpoint
|
||||||
|
-> NodeRuntime derives ingress
|
||||||
|
-> EndpointState validates and returns Local(Call)
|
||||||
|
-> NodeRuntime dispatches to matching Leaf::on_call
|
||||||
|
-> leaf queues LeafAction values
|
||||||
|
-> runtime retains actions for a later reducer pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outbound Leaf Call
|
||||||
|
|
||||||
|
```text
|
||||||
|
leaf queues LeafAction::SendCall
|
||||||
|
-> runtime validates permission and target
|
||||||
|
-> EndpointState builds/routes call
|
||||||
|
-> pending hook is reserved if needed
|
||||||
|
-> RuntimeEffect::SendFrame or RuntimeEffect::Local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disconnect
|
||||||
|
|
||||||
|
```text
|
||||||
|
connection closes or unregisters
|
||||||
|
-> mark connection Draining/Closed and advance generation
|
||||||
|
-> remove matching route entries
|
||||||
|
-> remove pending hooks associated with peer/subtree
|
||||||
|
-> remove active hooks associated with peer/subtree
|
||||||
|
-> notify or close leaf sessions
|
||||||
|
-> drop queued SendFrame effects with stale generation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Gaps In The Current Branch
|
||||||
|
|
||||||
|
- `LeafAction::SendCall`, `LeafAction::SendHookData`, and `LeafAction::FailHook`
|
||||||
|
are reduced by `NodeRuntime`; connection action variants are still unsupported
|
||||||
|
and must remain queued when encountered.
|
||||||
|
- 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
|
||||||
|
`Vec<String>`.
|
||||||
|
|
||||||
|
## Next Implementation Slice
|
||||||
|
|
||||||
|
Implement the next narrow connection-action path:
|
||||||
|
|
||||||
|
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 disconnect cleanup early.
|
||||||
@@ -16,7 +16,7 @@ Key routing rules:
|
|||||||
- Design system, brand → invoke design-consultation
|
- Design system, brand → invoke design-consultation
|
||||||
- Visual audit, design polish → invoke design-review
|
- Visual audit, design polish → invoke design-review
|
||||||
- Architecture review → invoke plan-eng-review
|
- Architecture review → invoke plan-eng-review
|
||||||
- Save progress, checkpoint, resume → invoke checkpoint
|
- Save progress, checkpoint, resume → invoke context-save or context-restore
|
||||||
- Code quality, health check → invoke health
|
- Code quality, health check → invoke health
|
||||||
|
|
||||||
## Execution standards
|
## Execution standards
|
||||||
@@ -25,12 +25,77 @@ Key routing rules:
|
|||||||
- Leave the project warning-free. Fix all compiler, linter, and tooling warnings before finishing. If a warning cannot be eliminated cleanly, silence it in the narrowest possible scope and add a short rationale.
|
- Leave the project warning-free. Fix all compiler, linter, and tooling warnings before finishing. If a warning cannot be eliminated cleanly, silence it in the narrowest possible scope and add a short rationale.
|
||||||
- Document code thoroughly. Add rustdoc, module docs, examples, and inline comments where they improve comprehension. Public APIs should be documented with clear meaning and examples. Non-obvious internal logic should also be documented. Comments should explain intent, invariants, and behavior, not restate syntax.
|
- Document code thoroughly. Add rustdoc, module docs, examples, and inline comments where they improve comprehension. Public APIs should be documented with clear meaning and examples. Non-obvious internal logic should also be documented. Comments should explain intent, invariants, and behavior, not restate syntax.
|
||||||
- Maintain clear architecture. Do not allow files or functions to grow without bound. When code becomes too large or mixes concerns, split it into smaller modules, helper files, or folders with clear names. Prefer structure that improves readability, navigation, and maintenance.
|
- Maintain clear architecture. Do not allow files or functions to grow without bound. When code becomes too large or mixes concerns, split it into smaller modules, helper files, or folders with clear names. Prefer structure that improves readability, navigation, and maintenance.
|
||||||
|
- If a file is longer than 500 lines, split it up however seen fit. Create a rust module in place of the file, then split each component of the file into it's own file. Split utils into their own files. If it's a really big struct, split the functions into their own files with pub(super) to prevent confusion.
|
||||||
|
- If a function is longer than 150 lines, it must be split up as well. In this case, create a master function around multiple 'steps' to this larger one, describing in more detail how it works with appropriate comments.
|
||||||
- Research library behavior when needed. Do not assume library APIs, feature flags, version compatibility, or known issues. Verify them, including online research when appropriate, before making decisions.
|
- Research library behavior when needed. Do not assume library APIs, feature flags, version compatibility, or known issues. Verify them, including online research when appropriate, before making decisions.
|
||||||
- Commit at every real milestone. Create a local git commit each time a meaningful milestone is reached. Commit messages must be accurate, specific, and reflect the actual change.
|
- Commit at every real milestone when implementation is allowed and the user has not forbidden commits. Create a local git commit each time a meaningful milestone is reached. Commit messages must be accurate, specific, and reflect the actual change.
|
||||||
- Explain unintuitive choices. Whenever an implementation, algorithm, or control flow could appear backwards, surprising, or overly indirect, add a short rationale comment or documentation note explaining why it is correct.
|
- Explain unintuitive choices. Whenever an implementation, algorithm, or control flow could appear backwards, surprising, or overly indirect, add a short rationale comment or documentation note explaining why it is correct.
|
||||||
- Track work with TODOs. Use a task list throughout the work so progress, remaining steps, and milestone boundaries stay explicit.
|
- Track work with TODOs. Use a task list throughout the work so progress, remaining steps, and milestone boundaries stay explicit.
|
||||||
- ALL Sub-agents must be told to read this file before continuing.
|
- ALL Sub-agents must be told to read this file before continuing.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Because everything must be documented, comments should look like the below. This is a very unimportant function that isn't called often. Use significantly more description for more important ones.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Attaches `strace` to `process` and decodes reads/writes on `fd`.
|
||||||
|
///
|
||||||
|
/// This is passive: it observes the legacy host's serial traffic and never
|
||||||
|
/// writes to the MCU device. It requires permission to attach to the target
|
||||||
|
/// process and will return an error if the process is not running.
|
||||||
|
pub fn trace_serial(process: &str, fd: u32) -> io::Result<()> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Human-readable mapping for Elegoo `DeviceSensorStatus` sensor ids.
|
||||||
|
///
|
||||||
|
/// Source trail:
|
||||||
|
/// - `serial-test/src/protocol/device_sensor_status.rs` shows `0x48` starts with
|
||||||
|
/// a stable `sensor_id` and existing traces contain ids `0`, `1`, and `3`.
|
||||||
|
/// - `config/cc2/printer_dsp.cfg` defines the corresponding CC2 sensors:
|
||||||
|
/// `[ztemperature_sensor box] sensor_pin=PH0 #GPADC0`, `[heater_bed]
|
||||||
|
/// sensor_pin=PH1 #GPADC1`, and `[extruder] sensor_pin=toolhead:PA3`.
|
||||||
|
/// - `serial-test` samples show sensor id `1` carrying extruder up/down telemetry
|
||||||
|
/// markers (`0x96`/`0x97`), so id `1` is the toolhead/extruder stream.
|
||||||
|
///
|
||||||
|
/// This is deliberately separate from `0x3d` live status: live `0x3d` fields are
|
||||||
|
/// useful telemetry, but they are not stable object ids in the captured stream.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SensorName { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add documentation for not what each struct and function does, but WHY as well. It's very important for debug purposes.
|
||||||
|
|
||||||
|
In the case that a function is either user-facing in a library, or is used widely enough in a project to be considered a reference, add comments describing an example in how to use the function or struct.
|
||||||
|
|
||||||
|
Also, add documentation inside of functions like the below:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn is_watertight(&self) -> bool {
|
||||||
|
// Create the map of edges with an approximate amount of unique edges
|
||||||
|
let mut edge_map: AHashMap<(usize, usize), usize> =
|
||||||
|
AHashMap::with_capacity(self.indices.len() * 3 / 2);
|
||||||
|
|
||||||
|
let mut check_edge = |a: usize, b: usize| {
|
||||||
|
// Always choose smaller edge first
|
||||||
|
let (a, b) = if b < a { (b, a) } else { (a, b) };
|
||||||
|
|
||||||
|
// Find the pair of edges in the hash map
|
||||||
|
*edge_map.entry((a, b)).or_insert(0) += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check each edge on each triangle
|
||||||
|
for (a, b, c) in &self.indices {
|
||||||
|
check_edge(*a, *b);
|
||||||
|
check_edge(*b, *c);
|
||||||
|
check_edge(*a, *c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all edges come in pairs
|
||||||
|
edge_map.iter().all(|(_, checked)| *checked == 2)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Plan mode rules
|
## Plan mode rules
|
||||||
|
|
||||||
- Plan mode is strictly read-only. When plan mode is active, do not edit files, write output files, change configuration, make commits, or perform any system modifications.
|
- Plan mode is strictly read-only. When plan mode is active, do not edit files, write output files, change configuration, make commits, or perform any system modifications.
|
||||||
|
|||||||
Generated
+613
-126
File diff suppressed because it is too large
Load Diff
+30
-71
@@ -4,10 +4,8 @@ cargo-features = ["trim-paths", "panic-immediate-abort"]
|
|||||||
members = [
|
members = [
|
||||||
"ush-obfuscate",
|
"ush-obfuscate",
|
||||||
"base62",
|
"base62",
|
||||||
"unshell-macros",
|
|
||||||
"unshell-protocol",
|
"unshell-leaves/leaf-pty",
|
||||||
"unshell-leaves",
|
|
||||||
"treetest",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -20,23 +18,27 @@ repository = "https://github.com/Astatin3/unshell"
|
|||||||
include = ["LICENSE", "**/*.rs", "Cargo.toml"]
|
include = ["LICENSE", "**/*.rs", "Cargo.toml"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
rkyv = "0.8.16"
|
rkyv = "0.8.16"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
static_init = "1.0.4"
|
static_init = "1.0.4"
|
||||||
syn = "2.0.117"
|
|
||||||
quote = "1.0.45"
|
|
||||||
proc-macro2 = "1.0.106"
|
|
||||||
portable-pty = "0.9.0"
|
portable-pty = "0.9.0"
|
||||||
crossbeam-channel = "0.5.15"
|
crossbeam-channel = "0.5.15"
|
||||||
|
const-random = "0.1.18"
|
||||||
|
|
||||||
|
|
||||||
|
ratatui = "0.30.0"
|
||||||
|
|
||||||
unshell = { path = "." }
|
unshell = { path = "." }
|
||||||
unshell-protocol = { path = "./unshell-protocol" }
|
# unshell-protocol = { path = "./unshell-protocol" }
|
||||||
unshell-leaves = { path = "./unshell-leaves" }
|
|
||||||
unshell-macros = { path = "./unshell-macros" }
|
|
||||||
|
|
||||||
# ush-obfuscate = { path = "./ush-obfuscate" }
|
# ush-obfuscate = { path = "./ush-obfuscate" }
|
||||||
# base62 = { path = "./base62" }
|
# base62 = { path = "./base62" }
|
||||||
|
|
||||||
|
# Leaves
|
||||||
|
leaf-pty = { path = "./unshell-leaves/leaf-pty" }
|
||||||
|
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "unshell"
|
name = "unshell"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
@@ -44,76 +46,33 @@ edition.workspace = true
|
|||||||
description = "Pure no_std implementation of the UnShell Protocol"
|
description = "Pure no_std implementation of the UnShell Protocol"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = ["counter_shuffle_feistel_lcg"]
|
||||||
|
|
||||||
log = []
|
log = []
|
||||||
log_debug = ["log", "dep:chrono"]
|
log_debug = ["log", "dep:chrono"]
|
||||||
|
|
||||||
# Leaf features
|
interface = []
|
||||||
leaf_endpoint = ["unshell-leaves/leaf_endpoint"]
|
interface_ratatui = ["interface", "dep:ratatui"]
|
||||||
leaf_tui = ["unshell-leaves/leaf_tui"]
|
|
||||||
|
|
||||||
# obfuscate_aes = ["ush-obfuscate/obfuscate_aes"]
|
counter_shuffle_none = []
|
||||||
# obfuscate_ref = ["ush-obfuscate/obfuscate_ref"]
|
counter_shuffle_feistel = []
|
||||||
|
counter_shuffle_feistel_lcg = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rkyv = { workspace = true }
|
rkyv = { workspace = true }
|
||||||
thiserror = { workspace = true, optional = true }
|
thiserror = { workspace = true, optional = true }
|
||||||
chrono = { workspace = true, optional = true }
|
chrono = { workspace = true, optional = true }
|
||||||
# ush-obfuscate = { workspace = true }
|
|
||||||
static_init = { workspace = true }
|
static_init = { workspace = true }
|
||||||
unshell-macros = { workspace = true }
|
|
||||||
unshell-protocol = { workspace = true }
|
const-random = { workspace = true }
|
||||||
unshell-leaves = { workspace = true }
|
|
||||||
|
ratatui = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
crossbeam-channel = { workspace = true }
|
crossbeam-channel.workspace = true
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "leaf_derive"
|
|
||||||
path = "examples/protocol/leaf_derive.rs"
|
|
||||||
|
|
||||||
[[example]]
|
[build-dependencies]
|
||||||
name = "crossbeam_channel_leaf"
|
|
||||||
path = "examples/protocol/crossbeam_channel_leaf.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "remote_shell_endpoint"
|
|
||||||
path = "examples/protocol/remote_shell_endpoint.rs"
|
|
||||||
required-features = ["leaf_endpoint"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "remote_shell_receive"
|
|
||||||
path = "examples/protocol/remote_shell_receive.rs"
|
|
||||||
required-features = ["leaf_endpoint"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "remote_shell_single_endpoint"
|
|
||||||
path = "examples/protocol/remote_shell_single_endpoint.rs"
|
|
||||||
required-features = ["leaf_endpoint"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "bench"
|
|
||||||
path = "examples/protocol/bench/bench.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "op_encode_call"
|
|
||||||
path = "examples/protocol/bench/op_encode_call.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "op_decode_call"
|
|
||||||
path = "examples/protocol/bench/op_decode_call.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "op_forward_call_receive"
|
|
||||||
path = "examples/protocol/bench/op_forward_call_receive.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "op_local_call_receive"
|
|
||||||
path = "examples/protocol/bench/op_local_call_receive.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "op_hook_data_receive"
|
|
||||||
path = "examples/protocol/bench/op_hook_data_receive.rs"
|
|
||||||
|
|
||||||
[profile.minimize]
|
[profile.minimize]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
@@ -136,4 +95,4 @@ unsafe_op_in_unsafe_fn = "warn"
|
|||||||
unused_import_braces = "warn"
|
unused_import_braces = "warn"
|
||||||
unused_lifetimes = "warn"
|
unused_lifetimes = "warn"
|
||||||
trivial_casts = "allow"
|
trivial_casts = "allow"
|
||||||
missing_docs = "warn"
|
# missing_docs = "warn"
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Template Leaf Interface Design
|
||||||
|
|
||||||
|
**Status:** Implemented draft
|
||||||
|
**Last updated:** 2026-05-31
|
||||||
|
**Primary use case:** Small generated leaf wrappers without proc-macro machinery
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Leaf generation now uses a declarative `unshell_leaf!` template instead of the old
|
||||||
|
`#[unshell_leaf]` proc macro. The goal is to make generated code obvious, closer to
|
||||||
|
an HTML template than an AST transformation.
|
||||||
|
|
||||||
|
The macro only fills slots:
|
||||||
|
|
||||||
|
- wrapper name
|
||||||
|
- user state type
|
||||||
|
- leaf id
|
||||||
|
- interface metadata
|
||||||
|
- named session families
|
||||||
|
- named procedure families
|
||||||
|
|
||||||
|
All real behavior lives in normal Rust helpers under `src/protocol/runtime.rs`.
|
||||||
|
Those helpers are testable without macro parsing, `syn`, `quote`, or generated name
|
||||||
|
inference.
|
||||||
|
|
||||||
|
## User Shape
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct FakePtyState {
|
||||||
|
pub active_count: usize,
|
||||||
|
pub total_opened: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
unshell_leaf! {
|
||||||
|
pub leaf FakePtyLeaf for FakePtyState {
|
||||||
|
id: LEAF_FAKE_PTY,
|
||||||
|
meta: unshell::protocol::LeafMeta {
|
||||||
|
name: "Fake PTY Leaf",
|
||||||
|
identifier: "dev.unshell.v1.pty",
|
||||||
|
version: "v0",
|
||||||
|
authors: unshell::alloc::vec!["ASTATIN3"],
|
||||||
|
},
|
||||||
|
sessions {
|
||||||
|
pty: PtySession,
|
||||||
|
}
|
||||||
|
procedures {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The field name before each session type is explicit. The macro does not invent a
|
||||||
|
field name from the Rust type.
|
||||||
|
|
||||||
|
## Generated Shape
|
||||||
|
|
||||||
|
The example above expands to the equivalent of:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct FakePtyLeaf {
|
||||||
|
state: FakePtyState,
|
||||||
|
outbox: LeafOutbox,
|
||||||
|
pty: SessionFamily<<PtySession as Session<FakePtyState>>::State>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper implements:
|
||||||
|
|
||||||
|
- `new(state)`
|
||||||
|
- `state()`
|
||||||
|
- `state_mut()`
|
||||||
|
- `active_session_count()`
|
||||||
|
- `pending_packet_count()`
|
||||||
|
- `Leaf::get_id()`
|
||||||
|
- `Leaf::update()`
|
||||||
|
- feature-gated `Leaf::update_interface()`
|
||||||
|
- feature-gated `Leaf::get_meta()`
|
||||||
|
- feature-gated `Leaf::render_ratatui()`
|
||||||
|
|
||||||
|
## Runtime Helpers
|
||||||
|
|
||||||
|
The macro delegates behavior to small helpers:
|
||||||
|
|
||||||
|
- `dispatch_session`
|
||||||
|
- `update_session_family`
|
||||||
|
- `dispatch_procedure`
|
||||||
|
- `flush_leaf_outbox`
|
||||||
|
- `flush_session_family`
|
||||||
|
- `flush_packet_queue_with_interface`
|
||||||
|
|
||||||
|
This keeps the macro readable. The helper functions own the mechanics of session
|
||||||
|
lookup, initialization, retry-safe flushing, and optional interface logging.
|
||||||
|
|
||||||
|
## Interface Store
|
||||||
|
|
||||||
|
`InterfaceStore` is caller-owned. It records packet flow and timing without putting
|
||||||
|
UI state inside `Endpoint` or the leaf wrapper.
|
||||||
|
|
||||||
|
```text
|
||||||
|
InterfaceStore
|
||||||
|
events: Vec<InterfaceEvent>
|
||||||
|
sessions: BTreeMap<SessionKey, SessionView>
|
||||||
|
procedures: BTreeMap<ProcedureKey, ProcedureView>
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated leaves receive an optional mutable store during `update_interface`. The
|
||||||
|
helpers create and update the appropriate session/procedure views when packets are
|
||||||
|
dispatched, sessions update, and outbound routes succeed or fail.
|
||||||
|
|
||||||
|
Time remains caller-supplied:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
interface.set_now_ns(Some(now_ns));
|
||||||
|
leaf.update_interface(endpoint, &mut interface);
|
||||||
|
```
|
||||||
|
|
||||||
|
No clock is embedded in the no_std protocol layer.
|
||||||
|
|
||||||
|
## Ratatui Rendering
|
||||||
|
|
||||||
|
Ratatui rendering is a plain feature-gated pass:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
leaf.render_ratatui(frame, area, &mut interface);
|
||||||
|
```
|
||||||
|
|
||||||
|
Session rendering is an associated function because session families are type-level
|
||||||
|
contracts, not stored objects:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn render_ratatui(
|
||||||
|
leaf: &LeafState,
|
||||||
|
session: &Self::State,
|
||||||
|
view: &mut SessionView,
|
||||||
|
frame: &mut ratatui::Frame<'_>,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Procedure rendering is also associated and renders from leaf state plus the caller
|
||||||
|
owned procedure view.
|
||||||
|
|
||||||
|
## Why This Replaced The Proc Macro
|
||||||
|
|
||||||
|
The old proc macro had to parse attributes, infer names, generate many code paths,
|
||||||
|
and duplicate runtime logic inside codegen. That made the generator harder to reason
|
||||||
|
about than the leaf behavior it was trying to simplify.
|
||||||
|
|
||||||
|
The new design is intentionally boring:
|
||||||
|
|
||||||
|
```text
|
||||||
|
macro template -> named fields and loops
|
||||||
|
runtime helpers -> behavior
|
||||||
|
caller InterfaceStore -> UI/log state
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole game.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
macro_rules! hashtest {
|
||||||
|
($input:tt) => {
|
||||||
|
($input, unshell::hash_32!($input))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAP: [(&str, u32); 6] = [
|
||||||
|
hashtest!("abc123"),
|
||||||
|
hashtest!("abc124"),
|
||||||
|
hashtest!("abc125"),
|
||||||
|
hashtest!("abc122"),
|
||||||
|
hashtest!("somethingelse"),
|
||||||
|
hashtest!("org.io.abc1234"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn main() {
|
||||||
|
for (a, b) in MAP {
|
||||||
|
println!("unshell::hash_32!(\"{}\") = {}", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
//! Protocol benchmark driver.
|
|
||||||
//!
|
|
||||||
//! Running the example normally prints the in-process benchmark table. Running it with `tools`
|
|
||||||
//! builds the standalone operation binaries and feeds them to external profiling tools.
|
|
||||||
|
|
||||||
use std::hint::black_box;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafSpec, LocalEvent, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
|
|
||||||
const SAMPLES: usize = 500;
|
|
||||||
const ITERS: usize = 10_000;
|
|
||||||
const TOOL_ITERS: usize = 10_000;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
if std::env::args().nth(1).as_deref() == Some("tools") {
|
|
||||||
run_external_tools();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("protocol benchmark");
|
|
||||||
println!("samples: {SAMPLES}");
|
|
||||||
println!("iterations/sample: {ITERS}");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let benches = [
|
|
||||||
bench_encode_call(),
|
|
||||||
bench_decode_call(),
|
|
||||||
bench_forward_call_receive(),
|
|
||||||
bench_local_call_receive(),
|
|
||||||
bench_hook_data_receive(),
|
|
||||||
];
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"{:32} {:>14} {:>14} {:>14}",
|
|
||||||
"benchmark", "mean ns/op", "stddev", "samples"
|
|
||||||
);
|
|
||||||
for bench in benches {
|
|
||||||
println!(
|
|
||||||
"{:32} {:>14.2} {:>14.2} {:>14}",
|
|
||||||
bench.name, bench.mean_ns, bench.stddev_ns, bench.samples
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("Run `cargo run --example bench -- tools` to build and execute");
|
|
||||||
println!("the standalone operation binaries under strace, perf, and heaptrack.");
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BenchResult {
|
|
||||||
name: &'static str,
|
|
||||||
mean_ns: f64,
|
|
||||||
stddev_ns: f64,
|
|
||||||
samples: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_encode_call() -> BenchResult {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["root"]),
|
|
||||||
dst_path: path(&["root", "worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let message = CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![7; 64],
|
|
||||||
response_hook: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
run_bench("encode_call", || {
|
|
||||||
let frame =
|
|
||||||
encode_packet(black_box(&header), black_box(&message)).expect("encode should work");
|
|
||||||
black_box(frame.len());
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_decode_call() -> BenchResult {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["root"]),
|
|
||||||
dst_path: path(&["root", "worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let message = CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![9; 64],
|
|
||||||
response_hook: None,
|
|
||||||
};
|
|
||||||
let frame = encode_packet(&header, &message).expect("seed frame should encode");
|
|
||||||
|
|
||||||
run_bench("decode_call", || {
|
|
||||||
let parsed = decode_frame(black_box(frame.as_slice())).expect("decode should work");
|
|
||||||
let call = parsed.deserialize_call().expect("call should deserialize");
|
|
||||||
black_box(call.data.len());
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_forward_call_receive() -> BenchResult {
|
|
||||||
run_prebuilt_bench(
|
|
||||||
"forward_call_receive",
|
|
||||||
build_forward_call_cases,
|
|
||||||
|(mut root, frame)| {
|
|
||||||
let outcome = root
|
|
||||||
.receive(&Ingress::Local, frame)
|
|
||||||
.expect("forward receive should work");
|
|
||||||
black_box(matches!(outcome, EndpointOutcome::Forward { .. }));
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_local_call_receive() -> BenchResult {
|
|
||||||
run_prebuilt_bench(
|
|
||||||
"local_call_receive",
|
|
||||||
build_local_call_cases,
|
|
||||||
|(mut endpoint, frame)| {
|
|
||||||
let outcome = endpoint
|
|
||||||
.receive(&Ingress::Parent, frame)
|
|
||||||
.expect("local call should work");
|
|
||||||
match black_box(outcome) {
|
|
||||||
EndpointOutcome::Local(LocalEvent::Call { .. }) => {}
|
|
||||||
other => panic!("expected local call event, got {other:?}"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_hook_data_receive() -> BenchResult {
|
|
||||||
run_prebuilt_bench(
|
|
||||||
"hook_data_receive",
|
|
||||||
build_hook_data_cases,
|
|
||||||
|(mut host, frame)| {
|
|
||||||
let outcome = host
|
|
||||||
.receive(&Ingress::Child(path(&["worker"])), frame)
|
|
||||||
.expect("hook data should work");
|
|
||||||
match black_box(outcome) {
|
|
||||||
EndpointOutcome::Local(LocalEvent::Data { .. }) => {}
|
|
||||||
other => panic!("expected local data event, got {other:?}"),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_bench(name: &'static str, mut op: impl FnMut()) -> BenchResult {
|
|
||||||
let mut samples = Vec::with_capacity(SAMPLES);
|
|
||||||
for _ in 0..SAMPLES {
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..ITERS {
|
|
||||||
op();
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed().as_nanos() as f64 / ITERS as f64;
|
|
||||||
samples.push(elapsed);
|
|
||||||
}
|
|
||||||
summarize(name, &samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_prebuilt_bench<T, F>(
|
|
||||||
name: &'static str,
|
|
||||||
mut build_cases: F,
|
|
||||||
mut op: impl FnMut(T),
|
|
||||||
) -> BenchResult
|
|
||||||
where
|
|
||||||
F: FnMut() -> Vec<T>,
|
|
||||||
{
|
|
||||||
let mut repeated = Vec::with_capacity(SAMPLES);
|
|
||||||
for _ in 0..SAMPLES {
|
|
||||||
let mut cases = build_cases();
|
|
||||||
assert_eq!(cases.len(), ITERS);
|
|
||||||
let start = Instant::now();
|
|
||||||
for case in cases.drain(..) {
|
|
||||||
op(case);
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed().as_nanos() as f64 / ITERS as f64;
|
|
||||||
repeated.push(elapsed);
|
|
||||||
}
|
|
||||||
summarize(name, &repeated)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_forward_call_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..ITERS)
|
|
||||||
.map(|_| {
|
|
||||||
let mut root = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["edge"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = root.allocate_hook_id();
|
|
||||||
let frame = root
|
|
||||||
.make_call(
|
|
||||||
path(&["edge", "worker"]),
|
|
||||||
Some(String::from("service")),
|
|
||||||
String::from("example.service.v1.invoke"),
|
|
||||||
Some(hook_id),
|
|
||||||
vec![1; 32],
|
|
||||||
)
|
|
||||||
.expect("seed call should encode");
|
|
||||||
(root, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_local_call_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..ITERS)
|
|
||||||
.map(|_| {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["worker"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![LeafSpec {
|
|
||||||
name: String::from("service"),
|
|
||||||
procedures: vec![String::from("example.service.v1.invoke")],
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: Vec::new(),
|
|
||||||
dst_path: path(&["worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
},
|
|
||||||
&CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![2; 32],
|
|
||||||
response_hook: Some(unshell::protocol::HookTarget {
|
|
||||||
hook_id: 42,
|
|
||||||
return_path: Vec::new(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("seed local call should encode");
|
|
||||||
(endpoint, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_hook_data_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..ITERS)
|
|
||||||
.map(|_| {
|
|
||||||
let mut host = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["worker"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = host.allocate_hook_id();
|
|
||||||
host.make_call(
|
|
||||||
path(&["worker"]),
|
|
||||||
None,
|
|
||||||
String::from("example.service.v1.invoke"),
|
|
||||||
Some(hook_id),
|
|
||||||
vec![3; 8],
|
|
||||||
)
|
|
||||||
.expect("seed active hook should encode");
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["worker"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&unshell::protocol::DataMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![4; 16],
|
|
||||||
end_hook: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("seed data should encode");
|
|
||||||
(host, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize(name: &'static str, samples: &[f64]) -> BenchResult {
|
|
||||||
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
|
|
||||||
let variance = samples
|
|
||||||
.iter()
|
|
||||||
.map(|sample| {
|
|
||||||
let delta = sample - mean;
|
|
||||||
delta * delta
|
|
||||||
})
|
|
||||||
.sum::<f64>()
|
|
||||||
/ samples.len() as f64;
|
|
||||||
|
|
||||||
BenchResult {
|
|
||||||
name,
|
|
||||||
mean_ns: mean,
|
|
||||||
stddev_ns: variance.sqrt(),
|
|
||||||
samples: samples.len(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| String::from(*part)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_external_tools() {
|
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
build_examples(root);
|
|
||||||
|
|
||||||
let ops = [
|
|
||||||
("encode_call", "op_encode_call"),
|
|
||||||
("decode_call", "op_decode_call"),
|
|
||||||
("forward_call_receive", "op_forward_call_receive"),
|
|
||||||
("local_call_receive", "op_local_call_receive"),
|
|
||||||
("hook_data_receive", "op_hook_data_receive"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let heap_dir = root.join("heaptrack-cli");
|
|
||||||
std::fs::create_dir_all(&heap_dir).expect("heaptrack-cli directory should be creatable");
|
|
||||||
|
|
||||||
for (name, binary) in ops {
|
|
||||||
let binary_path = root.join("target/debug/examples").join(binary);
|
|
||||||
println!();
|
|
||||||
println!("=== {name} ===");
|
|
||||||
run_binary(&binary_path, TOOL_ITERS, "direct run");
|
|
||||||
run_strace(&binary_path, TOOL_ITERS);
|
|
||||||
run_perf(&binary_path, TOOL_ITERS);
|
|
||||||
run_heaptrack(root, &heap_dir, name, &binary_path, TOOL_ITERS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_examples(root: &Path) {
|
|
||||||
run_command(
|
|
||||||
"cargo build --examples",
|
|
||||||
Command::new("cargo")
|
|
||||||
.arg("build")
|
|
||||||
.arg("--examples")
|
|
||||||
.current_dir(root),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_binary(binary: &Path, iterations: usize, label: &str) {
|
|
||||||
run_command(label, Command::new(binary).arg(iterations.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_strace(binary: &Path, iterations: usize) {
|
|
||||||
run_command(
|
|
||||||
"strace -c memory syscalls",
|
|
||||||
Command::new("strace")
|
|
||||||
.arg("-qq")
|
|
||||||
.arg("-c")
|
|
||||||
.arg("-e")
|
|
||||||
.arg("trace=brk,mmap,mremap,munmap,mprotect,madvise")
|
|
||||||
.arg(binary)
|
|
||||||
.arg(iterations.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_perf(binary: &Path, iterations: usize) {
|
|
||||||
run_command(
|
|
||||||
"perf stat",
|
|
||||||
Command::new("perf")
|
|
||||||
.arg("stat")
|
|
||||||
.arg("-e")
|
|
||||||
.arg("task-clock,cycles,instructions,branches,branch-misses,cache-references,cache-misses")
|
|
||||||
.arg(binary)
|
|
||||||
.arg(iterations.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_heaptrack(root: &Path, heap_dir: &Path, name: &str, binary: &Path, iterations: usize) {
|
|
||||||
let prefix = heap_dir.join(format!("{name}.zst"));
|
|
||||||
run_command(
|
|
||||||
"heaptrack --record-only",
|
|
||||||
Command::new("heaptrack")
|
|
||||||
.arg("--record-only")
|
|
||||||
.arg("-o")
|
|
||||||
.arg(&prefix)
|
|
||||||
.arg(binary)
|
|
||||||
.arg(iterations.to_string())
|
|
||||||
.current_dir(root),
|
|
||||||
);
|
|
||||||
|
|
||||||
let recorded = PathBuf::from(format!("{}.zst", prefix.display()));
|
|
||||||
run_command(
|
|
||||||
"heaptrack_print summary",
|
|
||||||
Command::new("heaptrack_print")
|
|
||||||
.arg("-f")
|
|
||||||
.arg(recorded)
|
|
||||||
.arg("-n")
|
|
||||||
.arg("4")
|
|
||||||
.arg("-s")
|
|
||||||
.arg("2")
|
|
||||||
.current_dir(root),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_command(label: &str, command: &mut Command) {
|
|
||||||
println!("--- {label} ---");
|
|
||||||
let output = command
|
|
||||||
.output()
|
|
||||||
.unwrap_or_else(|error| panic!("{label} failed to launch: {error}"));
|
|
||||||
if !output.stdout.is_empty() {
|
|
||||||
print!("{}", String::from_utf8_lossy(&output.stdout));
|
|
||||||
}
|
|
||||||
if !output.stderr.is_empty() {
|
|
||||||
print!("{}", String::from_utf8_lossy(&output.stderr));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"{label} failed with status {}",
|
|
||||||
output.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Standalone benchmark binary for `decode_call`.
|
|
||||||
|
|
||||||
#[path = "support/bench_common.rs"]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let iterations = common::iterations_from_args(1_000);
|
|
||||||
let checksum = common::run_decode_call(iterations);
|
|
||||||
println!("decode_call iterations={iterations} checksum={checksum}");
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Standalone benchmark binary for `encode_call`.
|
|
||||||
|
|
||||||
#[path = "support/bench_common.rs"]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let iterations = common::iterations_from_args(1_000);
|
|
||||||
let checksum = common::run_encode_call(iterations);
|
|
||||||
println!("encode_call iterations={iterations} checksum={checksum}");
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Standalone benchmark binary for `forward_call_receive`.
|
|
||||||
|
|
||||||
#[path = "support/bench_common.rs"]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let iterations = common::iterations_from_args(1_000);
|
|
||||||
let checksum = common::run_forward_call_receive(iterations);
|
|
||||||
println!("forward_call_receive iterations={iterations} checksum={checksum}");
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Standalone benchmark binary for `hook_data_receive`.
|
|
||||||
|
|
||||||
#[path = "support/bench_common.rs"]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let iterations = common::iterations_from_args(1_000);
|
|
||||||
let checksum = common::run_hook_data_receive(iterations);
|
|
||||||
println!("hook_data_receive iterations={iterations} checksum={checksum}");
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Standalone benchmark binary for `local_call_receive`.
|
|
||||||
|
|
||||||
#[path = "support/bench_common.rs"]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let iterations = common::iterations_from_args(1_000);
|
|
||||||
let checksum = common::run_local_call_receive(iterations);
|
|
||||||
println!("local_call_receive iterations={iterations} checksum={checksum}");
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
//! Shared helpers for the standalone benchmark operation binaries.
|
|
||||||
//!
|
|
||||||
//! These helpers keep each operation binary tiny while still exposing the same setup and checksum
|
|
||||||
//! logic to strace, perf, and heaptrack.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::hint::black_box;
|
|
||||||
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafSpec, LocalEvent, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
|
|
||||||
pub fn iterations_from_args(default: usize) -> usize {
|
|
||||||
std::env::args()
|
|
||||||
.nth(1)
|
|
||||||
.map(|value| {
|
|
||||||
value
|
|
||||||
.parse::<usize>()
|
|
||||||
.expect("iterations must be a positive integer")
|
|
||||||
})
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(never)]
|
|
||||||
pub fn run_encode_call(iterations: usize) -> usize {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["root"]),
|
|
||||||
dst_path: path(&["root", "worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let message = CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![7; 64],
|
|
||||||
response_hook: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut checksum = 0usize;
|
|
||||||
for _ in 0..iterations {
|
|
||||||
let frame =
|
|
||||||
encode_packet(black_box(&header), black_box(&message)).expect("encode should work");
|
|
||||||
checksum = checksum.wrapping_add(frame.len());
|
|
||||||
}
|
|
||||||
black_box(checksum)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(never)]
|
|
||||||
pub fn run_decode_call(iterations: usize) -> usize {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["root"]),
|
|
||||||
dst_path: path(&["root", "worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let message = CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![9; 64],
|
|
||||||
response_hook: None,
|
|
||||||
};
|
|
||||||
let frame = encode_packet(&header, &message).expect("seed frame should encode");
|
|
||||||
|
|
||||||
let mut checksum = 0usize;
|
|
||||||
for _ in 0..iterations {
|
|
||||||
let parsed = decode_frame(black_box(frame.as_slice())).expect("decode should work");
|
|
||||||
let call = parsed.deserialize_call().expect("call should deserialize");
|
|
||||||
checksum = checksum
|
|
||||||
.wrapping_add(call.data.len())
|
|
||||||
.wrapping_add(call.procedure_id.len())
|
|
||||||
.wrapping_add(call.response_hook.is_some() as usize);
|
|
||||||
}
|
|
||||||
black_box(checksum)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(never)]
|
|
||||||
pub fn run_forward_call_receive(iterations: usize) -> usize {
|
|
||||||
let cases = build_forward_call_cases(iterations);
|
|
||||||
run_cases(cases, |(mut root, frame)| {
|
|
||||||
let outcome = root
|
|
||||||
.receive(&Ingress::Local, frame)
|
|
||||||
.expect("forward receive should work");
|
|
||||||
match outcome {
|
|
||||||
EndpointOutcome::Forward { route, frame } => {
|
|
||||||
route_value(route).wrapping_add(frame.len())
|
|
||||||
}
|
|
||||||
EndpointOutcome::Local(_) => 0,
|
|
||||||
EndpointOutcome::Dropped => usize::from(true),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(never)]
|
|
||||||
pub fn run_local_call_receive(iterations: usize) -> usize {
|
|
||||||
let cases = build_local_call_cases(iterations);
|
|
||||||
run_cases(cases, |(mut endpoint, frame)| {
|
|
||||||
let outcome = endpoint
|
|
||||||
.receive(&Ingress::Parent, frame)
|
|
||||||
.expect("local call should work");
|
|
||||||
match outcome {
|
|
||||||
EndpointOutcome::Local(LocalEvent::Call { header, message }) => header
|
|
||||||
.dst_path
|
|
||||||
.len()
|
|
||||||
.wrapping_add(header.src_path.len())
|
|
||||||
.wrapping_add(header.dst_leaf.as_ref().map_or(0, String::len))
|
|
||||||
.wrapping_add(message.data.len())
|
|
||||||
.wrapping_add(message.procedure_id.len()),
|
|
||||||
other => panic!("expected local call event, got {other:?}"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(never)]
|
|
||||||
pub fn run_hook_data_receive(iterations: usize) -> usize {
|
|
||||||
let cases = build_hook_data_cases(iterations);
|
|
||||||
run_cases(cases, |(mut host, frame)| {
|
|
||||||
let outcome = host
|
|
||||||
.receive(&Ingress::Child(path(&["worker"])), frame)
|
|
||||||
.expect("hook data should work");
|
|
||||||
match outcome {
|
|
||||||
EndpointOutcome::Local(LocalEvent::Data {
|
|
||||||
header, message, ..
|
|
||||||
}) => (header.hook_id.unwrap_or_default() as usize)
|
|
||||||
.wrapping_add(message.data.len())
|
|
||||||
.wrapping_add(message.procedure_id.len())
|
|
||||||
.wrapping_add(message.end_hook as usize),
|
|
||||||
other => panic!("expected local data event, got {other:?}"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_cases<T>(cases: Vec<T>, mut op: impl FnMut(T) -> usize) -> usize {
|
|
||||||
let mut checksum = 0usize;
|
|
||||||
for case in cases {
|
|
||||||
checksum = checksum.wrapping_add(op(case));
|
|
||||||
}
|
|
||||||
black_box(checksum)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_forward_call_cases(
|
|
||||||
iterations: usize,
|
|
||||||
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..iterations)
|
|
||||||
.map(|_| {
|
|
||||||
let mut root = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["edge"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = root.allocate_hook_id();
|
|
||||||
let frame = root
|
|
||||||
.make_call(
|
|
||||||
path(&["edge", "worker"]),
|
|
||||||
Some(String::from("service")),
|
|
||||||
String::from("example.service.v1.invoke"),
|
|
||||||
Some(hook_id),
|
|
||||||
vec![1; 32],
|
|
||||||
)
|
|
||||||
.expect("seed call should encode");
|
|
||||||
(root, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_local_call_cases(
|
|
||||||
iterations: usize,
|
|
||||||
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..iterations)
|
|
||||||
.map(|_| {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["worker"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![LeafSpec {
|
|
||||||
name: String::from("service"),
|
|
||||||
procedures: vec![String::from("example.service.v1.invoke")],
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: Vec::new(),
|
|
||||||
dst_path: path(&["worker"]),
|
|
||||||
dst_leaf: Some(String::from("service")),
|
|
||||||
hook_id: None,
|
|
||||||
},
|
|
||||||
&CallMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![2; 32],
|
|
||||||
response_hook: Some(unshell::protocol::HookTarget {
|
|
||||||
hook_id: 42,
|
|
||||||
return_path: Vec::new(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("seed local call should encode");
|
|
||||||
(endpoint, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_hook_data_cases(
|
|
||||||
iterations: usize,
|
|
||||||
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
|
|
||||||
(0..iterations)
|
|
||||||
.map(|_| {
|
|
||||||
let mut host = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["worker"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = host.allocate_hook_id();
|
|
||||||
host.make_call(
|
|
||||||
path(&["worker"]),
|
|
||||||
None,
|
|
||||||
String::from("example.service.v1.invoke"),
|
|
||||||
Some(hook_id),
|
|
||||||
vec![3; 8],
|
|
||||||
)
|
|
||||||
.expect("seed active hook should encode");
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["worker"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&unshell::protocol::DataMessage {
|
|
||||||
procedure_id: String::from("example.service.v1.invoke"),
|
|
||||||
data: vec![4; 16],
|
|
||||||
end_hook: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("seed data should encode");
|
|
||||||
(host, frame)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| String::from(*part)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_value(route: unshell::protocol::tree::RouteDecision) -> usize {
|
|
||||||
match route {
|
|
||||||
unshell::protocol::tree::RouteDecision::Child(index) => index,
|
|
||||||
unshell::protocol::tree::RouteDecision::Local => usize::MAX - 2,
|
|
||||||
unshell::protocol::tree::RouteDecision::Parent => usize::MAX - 1,
|
|
||||||
unshell::protocol::tree::RouteDecision::Drop => usize::MAX,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
//! Crossbeam-channel router leaf example.
|
|
||||||
//!
|
|
||||||
//! This example wires a root controller to an `agent` node, promotes a staged
|
|
||||||
//! child connection on that agent via the `add_connection` procedure, and then
|
|
||||||
//! queries the grandchild's connection snapshot through a fully routed call/reply
|
|
||||||
//! exchange.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
|
||||||
use unshell::leaves::crossbeam_channel::{
|
|
||||||
ConnectionRequest, ConnectionSnapshot, CrossbeamChannelLeaf, CrossbeamEnvelope,
|
|
||||||
};
|
|
||||||
use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafRuntime, decode_call_input,
|
|
||||||
encode_call_reply,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let (mut agent, root_to_agent) = ChannelNode::new(path(&["agent"]));
|
|
||||||
let (mut child, agent_to_child) = ChannelNode::new(path(&["agent", "child"]));
|
|
||||||
let (agent_to_root, root_rx) = unbounded();
|
|
||||||
|
|
||||||
let mut root = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["agent"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
agent.stage_connection(Vec::new(), agent_to_root);
|
|
||||||
agent.connect_staged(Vec::new())?;
|
|
||||||
|
|
||||||
child.stage_connection(path(&["agent"]), root_to_agent.clone());
|
|
||||||
child.connect_staged(path(&["agent"]))?;
|
|
||||||
|
|
||||||
agent.stage_connection(path(&["agent", "child"]), agent_to_child);
|
|
||||||
|
|
||||||
call_root(
|
|
||||||
&mut root,
|
|
||||||
&root_to_agent,
|
|
||||||
&mut agent,
|
|
||||||
&mut child,
|
|
||||||
&root_rx,
|
|
||||||
path(&["agent"]),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("add_connection").expect("procedure exists"),
|
|
||||||
encode_call_reply(&ConnectionRequest {
|
|
||||||
peer_path: path(&["agent", "child"]),
|
|
||||||
})?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let reply = call_root(
|
|
||||||
&mut root,
|
|
||||||
&root_to_agent,
|
|
||||||
&mut agent,
|
|
||||||
&mut child,
|
|
||||||
&root_rx,
|
|
||||||
path(&["agent", "child"]),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("get_connections").expect("procedure exists"),
|
|
||||||
encode_call_reply(&())?,
|
|
||||||
)?;
|
|
||||||
let snapshot = decode_call_input::<ConnectionSnapshot>(reply.as_slice())?;
|
|
||||||
|
|
||||||
println!("child parent: {:?}", snapshot.parent);
|
|
||||||
println!("child children: {:?}", snapshot.children);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChannelNode {
|
|
||||||
runtime: LeafRuntime<CrossbeamChannelLeaf>,
|
|
||||||
rx: Receiver<CrossbeamEnvelope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelNode {
|
|
||||||
fn new(path: Vec<String>) -> (Self, Sender<CrossbeamEnvelope>) {
|
|
||||||
let (tx, rx) = unbounded();
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path,
|
|
||||||
None,
|
|
||||||
Vec::new(),
|
|
||||||
vec![CrossbeamChannelLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
runtime: LeafRuntime::new(endpoint, CrossbeamChannelLeaf::default()),
|
|
||||||
rx,
|
|
||||||
},
|
|
||||||
tx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stage_connection(&mut self, peer_path: Vec<String>, sender: Sender<CrossbeamEnvelope>) {
|
|
||||||
let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect_staged(&mut self, peer_path: Vec<String>) -> Result<(), Box<dyn Error>> {
|
|
||||||
let runtime = &mut self.runtime;
|
|
||||||
let mut leaf = core::mem::take(runtime.leaf_mut());
|
|
||||||
let result = leaf.connect_staged(runtime.endpoint_mut(), peer_path);
|
|
||||||
*runtime.leaf_mut() = leaf;
|
|
||||||
result?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain(&mut self) -> Result<usize, Box<dyn Error>> {
|
|
||||||
let mut processed = 0usize;
|
|
||||||
while let Ok(envelope) = self.rx.try_recv() {
|
|
||||||
let outcome = self
|
|
||||||
.runtime
|
|
||||||
.receive_routed(&envelope.ingress, envelope.frame)?;
|
|
||||||
self.runtime.route_forwarded(outcome.forwarded)?;
|
|
||||||
processed += 1;
|
|
||||||
}
|
|
||||||
Ok(processed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_root(
|
|
||||||
root: &mut ProtocolEndpoint,
|
|
||||||
root_to_agent: &Sender<CrossbeamEnvelope>,
|
|
||||||
agent: &mut ChannelNode,
|
|
||||||
child: &mut ChannelNode,
|
|
||||||
root_rx: &Receiver<CrossbeamEnvelope>,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
procedure_id: String,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
|
||||||
let hook_id = root.allocate_hook_id();
|
|
||||||
let outcome = root.send_call(
|
|
||||||
dst_path,
|
|
||||||
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
|
|
||||||
procedure_id,
|
|
||||||
Some(hook_id),
|
|
||||||
data,
|
|
||||||
)?;
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = outcome else {
|
|
||||||
return Err("root call did not forward".into());
|
|
||||||
};
|
|
||||||
root_to_agent.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
for _ in 0..16 {
|
|
||||||
let mut progress = 0usize;
|
|
||||||
progress += agent.drain()?;
|
|
||||||
progress += child.drain()?;
|
|
||||||
|
|
||||||
while let Ok(envelope) = root_rx.try_recv() {
|
|
||||||
progress += 1;
|
|
||||||
let outcome = root.receive(&envelope.ingress, envelope.frame)?;
|
|
||||||
if let EndpointOutcome::Local(event) = outcome {
|
|
||||||
match event {
|
|
||||||
unshell::protocol::tree::LocalEvent::Data { message, .. } => {
|
|
||||||
return Ok(message.data);
|
|
||||||
}
|
|
||||||
unshell::protocol::tree::LocalEvent::Fault { message, .. } => {
|
|
||||||
return Err(format!("routed call faulted: {:?}", message.fault).into());
|
|
||||||
}
|
|
||||||
unshell::protocol::tree::LocalEvent::Call { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if progress == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("timed out waiting for routed reply".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
//! Small end-to-end example for the `leaf!` and `Procedure` macros.
|
|
||||||
//!
|
|
||||||
//! This stays entirely local. A controller endpoint opens one hook-backed procedure against a
|
|
||||||
//! single in-process leaf runtime, and the example decodes the returned reply payload.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::{collections::BTreeMap, convert::Infallible, string::String};
|
|
||||||
|
|
||||||
use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
Call, ChildRoute, EndpointOutcome, HookKey, Ingress, OutgoingData, Procedure, ProcedureEffect,
|
|
||||||
ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
use unshell::protocol::{PacketType, decode_frame};
|
|
||||||
use unshell::{Procedure, leaf};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct EchoLeaf {
|
|
||||||
sessions: BTreeMap<HookKey, EchoOpen>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.echo", procedures = [EchoOpen], endpoint_struct = EchoLeaf)]
|
|
||||||
struct Echo;
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct EchoRequest {
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct EchoResponse {
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
|
|
||||||
#[procedure(leaf = EchoLeaf, name = "echo")]
|
|
||||||
struct EchoOpen {
|
|
||||||
prefix: String,
|
|
||||||
return_path: Vec<String>,
|
|
||||||
hook_id: u64,
|
|
||||||
sent_reply: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureStore<EchoOpen> for EchoLeaf {
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, EchoOpen> {
|
|
||||||
&mut self.sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Procedure<EchoLeaf> for EchoOpen {
|
|
||||||
type Error = Infallible;
|
|
||||||
type Input = EchoRequest;
|
|
||||||
|
|
||||||
fn open(_leaf: &mut EchoLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
|
||||||
let response_hook = call
|
|
||||||
.response_hook
|
|
||||||
.expect("example call declares a response hook");
|
|
||||||
Ok(Self {
|
|
||||||
prefix: call.input.text,
|
|
||||||
return_path: response_hook.return_path,
|
|
||||||
hook_id: response_hook.hook_id,
|
|
||||||
sent_reply: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll(_leaf: &mut EchoLeaf, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
if session.sent_reply {
|
|
||||||
return Ok(ProcedureEffect::default());
|
|
||||||
}
|
|
||||||
session.sent_reply = true;
|
|
||||||
Ok(ProcedureEffect::close(vec![OutgoingData {
|
|
||||||
dst_path: session.return_path.clone(),
|
|
||||||
hook_id: session.hook_id,
|
|
||||||
procedure_id: EchoOpen::protocol_procedure_id(),
|
|
||||||
data: unshell::protocol::tree::encode_call_reply(&EchoResponse {
|
|
||||||
text: format!("echo: {}", session.prefix),
|
|
||||||
})
|
|
||||||
.expect("response should encode"),
|
|
||||||
end_hook: true,
|
|
||||||
}]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![EchoLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime = ProcedureRuntime::<EchoLeaf, EchoOpen>::new(endpoint, EchoLeaf::default());
|
|
||||||
|
|
||||||
let mut controller = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute {
|
|
||||||
path: path(&["agent"]),
|
|
||||||
registered: true,
|
|
||||||
}],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = controller.allocate_hook_id();
|
|
||||||
let controller_outcome = controller.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(EchoLeaf::protocol_leaf_name()),
|
|
||||||
EchoOpen::protocol_procedure_id(),
|
|
||||||
Some(hook_id),
|
|
||||||
unshell::protocol::tree::encode_call_reply(&EchoRequest {
|
|
||||||
text: String::from("hello leaf"),
|
|
||||||
})?,
|
|
||||||
)?;
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = controller_outcome else {
|
|
||||||
return Err("expected controller to forward call".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let receive_outcome = runtime.receive(&Ingress::Parent, frame)?;
|
|
||||||
assert!(receive_outcome.frames.is_empty());
|
|
||||||
let outcome = runtime.poll()?;
|
|
||||||
let [response_frame] = outcome.frames.as_slice() else {
|
|
||||||
return Err("expected one response frame".into());
|
|
||||||
};
|
|
||||||
let parsed = decode_frame(response_frame.as_slice())?;
|
|
||||||
assert_eq!(parsed.packet_type(), PacketType::Data);
|
|
||||||
let response = unshell::protocol::tree::decode_call_input::<EchoResponse>(
|
|
||||||
parsed.deserialize_data()?.data.as_slice(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
assert_eq!(EchoLeaf::protocol_leaf_name(), "org.example.v1.echo");
|
|
||||||
assert_eq!(response.text, "echo: hello leaf");
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"leaf={} procedure={} response={}",
|
|
||||||
EchoLeaf::protocol_leaf_name(),
|
|
||||||
EchoOpen::protocol_procedure_id(),
|
|
||||||
response.text,
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//! Remote shell endpoint example.
|
|
||||||
//!
|
|
||||||
//! This binary acts as the single remote-shell endpoint process. It connects to the controller
|
|
||||||
//! example over TCP, feeds inbound frames into the `ProcedureRuntime`, and flushes any resulting
|
|
||||||
//! protocol frames back to the controller.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::sync::mpsc::RecvTimeoutError;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use unshell::leaves::remote_shell;
|
|
||||||
use unshell::protocol::tree::{Ingress, ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let mut stream = TcpStream::connect(remote_shell::endpoint::LISTEN_ADDR)?;
|
|
||||||
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
agent_path(),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![remote_shell::endpoint::RemoteShell::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime = ProcedureRuntime::<
|
|
||||||
remote_shell::endpoint::RemoteShell,
|
|
||||||
remote_shell::endpoint::Open,
|
|
||||||
>::new(endpoint, remote_shell::endpoint::RemoteShell::default());
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"connected to controller at {}",
|
|
||||||
remote_shell::endpoint::LISTEN_ADDR
|
|
||||||
);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match frame_rx.recv_timeout(Duration::from_millis(25)) {
|
|
||||||
Ok(result) => {
|
|
||||||
let frame = result?;
|
|
||||||
let outcome = runtime.receive(&Ingress::Parent, frame)?;
|
|
||||||
remote_shell::endpoint::write_frames(&mut stream, &outcome.frames)?;
|
|
||||||
}
|
|
||||||
Err(RecvTimeoutError::Timeout) => {}
|
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = runtime.poll()?;
|
|
||||||
remote_shell::endpoint::write_frames(&mut stream, &outcome.frames)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_path() -> Vec<String> {
|
|
||||||
vec![String::from("agent")]
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//! Remote shell controller example.
|
|
||||||
//!
|
|
||||||
//! This binary listens for the endpoint example, opens one remote shell session, sends a few
|
|
||||||
//! commands, and prints returned hook data until the shell closes.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::net::TcpListener;
|
|
||||||
|
|
||||||
use unshell::leaves::remote_shell;
|
|
||||||
use unshell::leaves::remote_shell::OpenRequest;
|
|
||||||
use unshell::protocol::tree::encode_call_reply;
|
|
||||||
use unshell::protocol::tree::{
|
|
||||||
ChildRoute, Endpoint, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let listener = TcpListener::bind(remote_shell::endpoint::LISTEN_ADDR)?;
|
|
||||||
println!("listening on {}", remote_shell::endpoint::LISTEN_ADDR);
|
|
||||||
|
|
||||||
let (mut stream, peer_addr) = listener.accept()?;
|
|
||||||
println!("accepted endpoint connection from {peer_addr}");
|
|
||||||
|
|
||||||
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(agent_path())],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = endpoint.allocate_hook_id();
|
|
||||||
let shell_leaf_name = remote_shell::endpoint::RemoteShell::protocol_leaf_name();
|
|
||||||
let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id();
|
|
||||||
|
|
||||||
remote_shell::endpoint::send_forward(
|
|
||||||
&mut stream,
|
|
||||||
endpoint.send_call(
|
|
||||||
agent_path(),
|
|
||||||
Some(shell_leaf_name),
|
|
||||||
open_procedure.clone(),
|
|
||||||
Some(hook_id),
|
|
||||||
encode_call_reply(&OpenRequest).expect("remote shell open payload should encode"),
|
|
||||||
)?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
for (index, command) in ["pwd\n", "whoami\n", "exit\n"].iter().enumerate() {
|
|
||||||
remote_shell::endpoint::send_forward(
|
|
||||||
&mut stream,
|
|
||||||
endpoint.send_data(
|
|
||||||
agent_path(),
|
|
||||||
hook_id,
|
|
||||||
open_procedure.clone(),
|
|
||||||
command.as_bytes().to_vec(),
|
|
||||||
index == 2,
|
|
||||||
)?,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for result in frame_rx {
|
|
||||||
let frame = result?;
|
|
||||||
let outcome = endpoint.receive(&Ingress::Child(agent_path()), frame)?;
|
|
||||||
let EndpointOutcome::Local(event) = outcome else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match event {
|
|
||||||
LocalEvent::Data { message, .. } => {
|
|
||||||
print!("{}", String::from_utf8_lossy(&message.data));
|
|
||||||
|
|
||||||
if message.end_hook {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LocalEvent::Fault { message, .. } => {
|
|
||||||
eprintln!("received protocol fault: 0x{:02X}", message.fault.0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
LocalEvent::Call { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_path() -> Vec<String> {
|
|
||||||
vec![String::from("agent")]
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//! Smallest in-process `remote_shell` declaration example.
|
|
||||||
//!
|
|
||||||
//! This example hosts exactly one protocol endpoint with exactly one leaf and performs a local
|
|
||||||
//! introspection request against that leaf. The important detail is that the endpoint metadata is
|
|
||||||
//! taken from `remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()`, which is
|
|
||||||
//! generated by the `leaf!` declaration in `unshell-leaves/src/remote_shell/mod.rs`.
|
|
||||||
//!
|
|
||||||
//! It does not open any sockets or spawn a shell process, so it is the easiest place to verify
|
|
||||||
//! that the shared compile-time leaf declaration and the generated endpoint host metadata line up.
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use unshell::create_endpoint;
|
|
||||||
use unshell::leaves::remote_shell;
|
|
||||||
use unshell::protocol::tree::{Endpoint, EndpointOutcome, LocalEvent, ProtocolEndpoint};
|
|
||||||
use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection};
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let mut endpoint: ProtocolEndpoint =
|
|
||||||
create_endpoint!("agent", remote_shell::endpoint::RemoteShell::default());
|
|
||||||
let leaf_spec = remote_shell::endpoint::RemoteShell::protocol_leaf_spec();
|
|
||||||
|
|
||||||
let hook_id = endpoint.allocate_hook_id();
|
|
||||||
let outcome = endpoint.send_call(
|
|
||||||
Vec::new(),
|
|
||||||
Some(remote_shell::endpoint::RemoteShell::protocol_leaf_name()),
|
|
||||||
INTROSPECTION_PROCEDURE_ID,
|
|
||||||
Some(hook_id),
|
|
||||||
Vec::new(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let EndpointOutcome::Local(LocalEvent::Data { message, .. }) = outcome else {
|
|
||||||
return Err("expected one local introspection response".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = unshell::protocol::tree::decode_call_input::<LeafIntrospection>(&message.data)?;
|
|
||||||
println!(
|
|
||||||
"remote-shell examples normally listen on {}",
|
|
||||||
remote_shell::endpoint::LISTEN_ADDR
|
|
||||||
);
|
|
||||||
println!("endpoint id: {:?}", endpoint.local_id());
|
|
||||||
println!("endpoint path: {:?}", endpoint.path());
|
|
||||||
println!("declared leaf: {}", leaf_spec.name);
|
|
||||||
println!("leaf: {}", payload.leaf_name);
|
|
||||||
println!("procedures: {:?}", payload.procedures);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/// Performs a deterministic pseudo-random shuffle of a 16-bit index.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `index` - The input value (0..65536).
|
||||||
|
/// * `seed` - The 32-bit seed acting as the encryption key.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A unique 16-bit shuffled value.
|
||||||
|
pub fn feistel_shuffle(index: u16, seed: u32) -> u16 {
|
||||||
|
// Split 16-bit index into two 8-bit halves
|
||||||
|
let mut l = ((index >> 8) & 0xFF) as u8;
|
||||||
|
let mut r = (index & 0xFF) as u8;
|
||||||
|
|
||||||
|
// Perform 4 rounds of Feistel mixing
|
||||||
|
for round in 0..4 {
|
||||||
|
// Derive sub-key: Rotate seed and add golden ratio constant
|
||||||
|
let rot_amount = (round * 5) % 32;
|
||||||
|
let sub_key = seed
|
||||||
|
.rotate_left(rot_amount)
|
||||||
|
.wrapping_add(round.wrapping_mul(0x9E3779B9));
|
||||||
|
|
||||||
|
// Round function F: Simple multiplicative hash mixing R and sub_key
|
||||||
|
// We cast to u32 for multiplication to avoid overflow, then mask back to 8 bits
|
||||||
|
let r_u32 = r as u32;
|
||||||
|
let hash_val = ((r_u32.wrapping_mul(sub_key)) ^ (r_u32 >> 4)) as u8 & 0xFF;
|
||||||
|
|
||||||
|
// Feistel step: New L = Old R, New R = Old L XOR F(R, key)
|
||||||
|
let temp = l;
|
||||||
|
l = r;
|
||||||
|
r = temp ^ hash_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recombine halves
|
||||||
|
((l as u16) << 8) | (r as u16)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::crypto::feistel_shuffle;
|
||||||
|
|
||||||
|
#[cfg(feature = "counter_shuffle_none")]
|
||||||
|
pub type Counter = NoShuffle;
|
||||||
|
#[cfg(feature = "counter_shuffle_feistel")]
|
||||||
|
pub type Counter = FeistelShuffle;
|
||||||
|
#[cfg(feature = "counter_shuffle_feistel_lcg")]
|
||||||
|
pub type Counter = FeistelLCGShuffle;
|
||||||
|
|
||||||
|
const NONCE16_1: u16 = const_random::const_random!(u16);
|
||||||
|
const NONCE16_2: u16 = const_random::const_random!(u16);
|
||||||
|
const NONCE32: u32 = const_random::const_random!(u32);
|
||||||
|
|
||||||
|
/// Odd additive step used by [`FeistelShuffle`] before applying the permutation.
|
||||||
|
///
|
||||||
|
/// A step through a `u16` counter only visits every possible value when it is
|
||||||
|
/// coprime with `2^16`; for powers of two, that means the step must be odd. Without
|
||||||
|
/// this constraint, a randomized even step can cycle through a subset of values and
|
||||||
|
/// collide before the hook id space is exhausted.
|
||||||
|
const FEISTEL_STEP: u16 = NONCE16_2 | 1;
|
||||||
|
|
||||||
|
pub struct NoShuffle(u16);
|
||||||
|
|
||||||
|
/// Linear shuffle, no randomization, just a random starting point and step size
|
||||||
|
impl NoShuffle {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(NONCE16_1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) -> u16 {
|
||||||
|
self.0 = self.0.wrapping_add(1);
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuffle all 16 bit numbers, an actual shuffle
|
||||||
|
/// But this still stores local values in a linear format
|
||||||
|
pub struct FeistelShuffle(u16, u32);
|
||||||
|
|
||||||
|
impl FeistelShuffle {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(NONCE16_1, NONCE32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) -> u16 {
|
||||||
|
self.0 = self.0.wrapping_add(FEISTEL_STEP);
|
||||||
|
feistel_shuffle(self.0, self.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear recursive shuffle,
|
||||||
|
/// feeds back into itself and doesn't store the actual state.
|
||||||
|
/// Harder to decompile
|
||||||
|
pub struct FeistelLCGShuffle {
|
||||||
|
state: u16,
|
||||||
|
a: u16, // Multiplier (must be 1 mod 4)
|
||||||
|
c: u16, // Increment (must be odd)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeistelLCGShuffle {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let seed = NONCE32;
|
||||||
|
let a = (((seed & 0x3FFF) as u16) << 2) | 1;
|
||||||
|
let c = ((seed >> 16) as u16) | 1;
|
||||||
|
Self { state: 0, a, c }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) -> u16 {
|
||||||
|
// 1. Advance state using LCG (Guarantees single cycle of 65536)
|
||||||
|
self.state = self.state.wrapping_mul(self.a).wrapping_add(self.c);
|
||||||
|
|
||||||
|
// 2. Apply Feistel shuffle to the state (Adds randomness)
|
||||||
|
feistel_shuffle(self.state, self.a as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use alloc::string::String;
|
||||||
|
|
||||||
|
// TODO: Make this seed dependent on env var;
|
||||||
|
pub const GLOBAL_SEED: u32 = 0xDEAFBEEF;
|
||||||
|
// pub const GLOBAL_NONCE: u32 = {
|
||||||
|
// let time = match u128::from_str_radix(env!("BUILD_TIME"), 10) {
|
||||||
|
// Ok(i) => i,
|
||||||
|
// Err(_) => panic!("Failed to parse BUILD_TIME"),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// GLOBAL_SEED ^ (time as u32)
|
||||||
|
// };
|
||||||
|
|
||||||
|
mod feistel;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod feistel_state;
|
||||||
|
mod sha256;
|
||||||
|
|
||||||
|
pub use feistel::feistel_shuffle;
|
||||||
|
pub use feistel_state::{Counter, FeistelLCGShuffle, FeistelShuffle, NoShuffle};
|
||||||
|
pub use sha256::sha256;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! hash_256 {
|
||||||
|
($s:literal) => {{
|
||||||
|
// string literal arm
|
||||||
|
const HASH: [u8; 32] = $crate::crypto::sha256($s.as_bytes());
|
||||||
|
HASH
|
||||||
|
}};
|
||||||
|
($n:expr) => {{
|
||||||
|
// integer/expression arm
|
||||||
|
const BYTES: [u8; 8] = ($n as u64).to_be_bytes();
|
||||||
|
const HASH: [u8; 32] = $crate::crypto::sha256(&BYTES);
|
||||||
|
HASH
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! hash_32 {
|
||||||
|
($s:literal) => {{
|
||||||
|
// string literal arm
|
||||||
|
const HASH: [u8; 32] = $crate::crypto::sha256($s.as_bytes());
|
||||||
|
const RESULT: u32 = u32::from_be_bytes([HASH[0], HASH[8], HASH[16], HASH[24]]);
|
||||||
|
RESULT
|
||||||
|
}};
|
||||||
|
($n:expr) => {{
|
||||||
|
// integer/expression arm
|
||||||
|
const BYTES: [u8; 8] = ($n as u64).to_be_bytes();
|
||||||
|
const HASH: [u8; 32] = $crate::crypto::sha256(&BYTES);
|
||||||
|
const RESULT: u32 = u32::from_be_bytes([HASH[0], HASH[8], HASH[16], HASH[24]]);
|
||||||
|
RESULT
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_string_32(input: String) -> u32 {
|
||||||
|
let hash: [u8; 32] = sha256(input.as_bytes());
|
||||||
|
u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_str_32(input: &str) -> u32 {
|
||||||
|
let hash: [u8; 32] = sha256(input.as_bytes());
|
||||||
|
u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_32(input: u32) -> u32 {
|
||||||
|
let hash: [u8; 32] = sha256(&input.to_be_bytes());
|
||||||
|
u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]])
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// ── Round constants ──────────────────────────────────────────────────────────
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
||||||
|
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
||||||
|
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
||||||
|
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
||||||
|
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
||||||
|
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
||||||
|
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
||||||
|
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Initial hash values ──────────────────────────────────────────────────────
|
||||||
|
const H: [u32; 8] = [
|
||||||
|
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
|
||||||
|
];
|
||||||
|
// ── Internals ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns what byte `pos` should hold in the padded SHA-256 message,
|
||||||
|
/// without ever materialising the full padded buffer.
|
||||||
|
const fn padded_byte(input: &[u8], pos: usize, padded_len: usize) -> u8 {
|
||||||
|
let bit_len = (input.len() as u64) * 8;
|
||||||
|
if pos < input.len() {
|
||||||
|
input[pos]
|
||||||
|
} else if pos == input.len() {
|
||||||
|
0x80
|
||||||
|
} else if pos >= padded_len - 8 {
|
||||||
|
// Big-endian 64-bit length: byte 0 is the most significant.
|
||||||
|
let byte_index = pos - (padded_len - 8);
|
||||||
|
(bit_len >> (56 - byte_index * 8)) as u8
|
||||||
|
} else {
|
||||||
|
0x00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 compression: mixes one 64-byte block into the hash state.
|
||||||
|
const fn compress(state: &mut [u32; 8], block: &[u8; 64]) {
|
||||||
|
// Build the 64-word message schedule from the 16-word block.
|
||||||
|
let mut w = [0u32; 64];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < 16 {
|
||||||
|
w[i] = ((block[i * 4] as u32) << 24)
|
||||||
|
| ((block[i * 4 + 1] as u32) << 16)
|
||||||
|
| ((block[i * 4 + 2] as u32) << 8)
|
||||||
|
| (block[i * 4 + 3] as u32);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
while i < 64 {
|
||||||
|
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||||
|
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||||
|
w[i] = w[i - 16]
|
||||||
|
.wrapping_add(s0)
|
||||||
|
.wrapping_add(w[i - 7])
|
||||||
|
.wrapping_add(s1);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise working variables from current hash state.
|
||||||
|
let mut a = state[0];
|
||||||
|
let mut b = state[1];
|
||||||
|
let mut c = state[2];
|
||||||
|
let mut d = state[3];
|
||||||
|
let mut e = state[4];
|
||||||
|
let mut f = state[5];
|
||||||
|
let mut g = state[6];
|
||||||
|
let mut h = state[7];
|
||||||
|
|
||||||
|
// 64 rounds.
|
||||||
|
i = 0;
|
||||||
|
while i < 64 {
|
||||||
|
let sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||||
|
let ch = (e & f) ^ ((!e) & g);
|
||||||
|
let temp1 = h
|
||||||
|
.wrapping_add(sigma1)
|
||||||
|
.wrapping_add(ch)
|
||||||
|
.wrapping_add(K[i])
|
||||||
|
.wrapping_add(w[i]);
|
||||||
|
|
||||||
|
let sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||||
|
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
let temp2 = sigma0.wrapping_add(maj);
|
||||||
|
|
||||||
|
h = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = d.wrapping_add(temp1);
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = temp1.wrapping_add(temp2);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the compressed chunk back into the hash state.
|
||||||
|
state[0] = state[0].wrapping_add(a);
|
||||||
|
state[1] = state[1].wrapping_add(b);
|
||||||
|
state[2] = state[2].wrapping_add(c);
|
||||||
|
state[3] = state[3].wrapping_add(d);
|
||||||
|
state[4] = state[4].wrapping_add(e);
|
||||||
|
state[5] = state[5].wrapping_add(f);
|
||||||
|
state[6] = state[6].wrapping_add(g);
|
||||||
|
state[7] = state[7].wrapping_add(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns the SHA-256 digest of `input` as 32 raw bytes.
|
||||||
|
pub const fn sha256(input: &[u8]) -> [u8; 32] {
|
||||||
|
// Padded length is the next multiple of 64 that fits input + 1 (0x80) + 8 (length).
|
||||||
|
let padded_len = ((input.len() + 9 + 63) / 64) * 64;
|
||||||
|
let mut state = H;
|
||||||
|
let mut block_start = 0;
|
||||||
|
|
||||||
|
while block_start < padded_len {
|
||||||
|
// Assemble the current 64-byte block using the virtual padded view.
|
||||||
|
let mut block = [0u8; 64];
|
||||||
|
let mut j = 0;
|
||||||
|
while j < 64 {
|
||||||
|
block[j] = padded_byte(input, block_start + j, padded_len);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
compress(&mut state, &block);
|
||||||
|
block_start += 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialise the 8×u32 state as big-endian bytes.
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < 8 {
|
||||||
|
out[i * 4] = (state[i] >> 24) as u8;
|
||||||
|
out[i * 4 + 1] = (state[i] >> 16) as u8;
|
||||||
|
out[i * 4 + 2] = (state[i] >> 8) as u8;
|
||||||
|
out[i * 4 + 3] = state[i] as u8;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use crate::crypto::{FeistelLCGShuffle, FeistelShuffle, NoShuffle};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_linear_shuffle() {
|
||||||
|
let mut seen = [false; 65536];
|
||||||
|
let mut counter = NoShuffle::new();
|
||||||
|
for _ in 0..65535 {
|
||||||
|
let val = counter.next();
|
||||||
|
|
||||||
|
assert!(!seen[val as usize], "Collision detected");
|
||||||
|
|
||||||
|
seen[val as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_feistel_shuffle() {
|
||||||
|
let mut seen = [false; 65536];
|
||||||
|
let mut counter = FeistelShuffle::new();
|
||||||
|
for _ in 0..65535 {
|
||||||
|
let val = counter.next();
|
||||||
|
|
||||||
|
assert!(!seen[val as usize], "Collision detected");
|
||||||
|
|
||||||
|
seen[val as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fristel_lcg_shuffle() {
|
||||||
|
let mut seen = [false; 65536];
|
||||||
|
let mut counter = FeistelLCGShuffle::new();
|
||||||
|
for _ in 0..65535 {
|
||||||
|
let val = counter.next();
|
||||||
|
|
||||||
|
assert!(!seen[val as usize], "Collision detected");
|
||||||
|
|
||||||
|
seen[val as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::protocol::{EndpointError, HookID, Packet, SessionStatus};
|
||||||
|
|
||||||
|
/// Ordered event stored by [`crate::interface::InterfaceStore`].
|
||||||
|
///
|
||||||
|
/// Events are append-only. Views store indices into this list instead of copying the
|
||||||
|
/// same packet-flow records into every renderable bucket.
|
||||||
|
pub struct InterfaceEvent {
|
||||||
|
/// Monotonic event sequence assigned by the interface store.
|
||||||
|
pub sequence: u64,
|
||||||
|
|
||||||
|
/// Caller-provided timestamp, if the frontend supplied one.
|
||||||
|
pub time_ns: Option<u64>,
|
||||||
|
|
||||||
|
/// Leaf id that emitted or handled the event.
|
||||||
|
pub leaf_id: u32,
|
||||||
|
|
||||||
|
/// Detailed event payload.
|
||||||
|
pub kind: InterfaceEventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface-visible event emitted by generated helpers.
|
||||||
|
pub enum InterfaceEventKind {
|
||||||
|
/// A packet was delivered to a generated leaf.
|
||||||
|
Inbound { packet: Packet },
|
||||||
|
|
||||||
|
/// A packet was queued into an already-live session inbox.
|
||||||
|
SessionPacketQueued { procedure_id: u32, hook_id: HookID },
|
||||||
|
|
||||||
|
/// A hook-backed session was created successfully.
|
||||||
|
SessionCreated {
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
finished_ns: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A packet could not create a new session.
|
||||||
|
SessionRejected {
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
finished_ns: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// One live session received an update tick.
|
||||||
|
SessionUpdated {
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
status: SessionStatus,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
finished_ns: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// One one-shot procedure handler ran.
|
||||||
|
ProcedureCalled {
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
finished_ns: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A packet was emitted by leaf logic before route retry handling.
|
||||||
|
OutboundQueued { packet: Packet },
|
||||||
|
|
||||||
|
/// A queued outbound packet is about to enter endpoint routing.
|
||||||
|
RouteAttempt { packet: Packet },
|
||||||
|
|
||||||
|
/// Endpoint routing accepted a queued outbound packet.
|
||||||
|
RouteSuccess { packet: Packet },
|
||||||
|
|
||||||
|
/// Endpoint routing rejected a queued outbound packet.
|
||||||
|
RouteFailure {
|
||||||
|
packet: Packet,
|
||||||
|
error: EndpointError,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::protocol::HookID;
|
||||||
|
|
||||||
|
/// Stable identity for one generated session view.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct SessionKey {
|
||||||
|
/// Leaf id that owns the generated session family.
|
||||||
|
pub leaf_id: u32,
|
||||||
|
|
||||||
|
/// Procedure id shared by every packet in the session family.
|
||||||
|
pub procedure_id: u32,
|
||||||
|
|
||||||
|
/// Hook id for the live session instance.
|
||||||
|
pub hook_id: HookID,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable identity for one generated procedure view.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct ProcedureKey {
|
||||||
|
/// Leaf id that owns the generated procedure family.
|
||||||
|
pub leaf_id: u32,
|
||||||
|
|
||||||
|
/// Procedure id handled by this one-shot procedure family.
|
||||||
|
pub procedure_id: u32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Caller-owned interface state for UI frontends.
|
||||||
|
//!
|
||||||
|
//! Protocol leaves stay headless. When a UI wants packet flow, timing, or render
|
||||||
|
//! state, it passes an [`InterfaceStore`] through the feature-gated interface path.
|
||||||
|
|
||||||
|
mod event;
|
||||||
|
mod key;
|
||||||
|
mod store;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
pub use event::{InterfaceEvent, InterfaceEventKind};
|
||||||
|
pub use key::{ProcedureKey, SessionKey};
|
||||||
|
pub use store::InterfaceStore;
|
||||||
|
pub use view::{ProcedureView, SessionView, SessionViewStatus};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
use alloc::{collections::BTreeMap, vec::Vec};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
interface::{
|
||||||
|
InterfaceEvent, InterfaceEventKind, ProcedureKey, ProcedureView, SessionKey, SessionView,
|
||||||
|
SessionViewStatus,
|
||||||
|
},
|
||||||
|
protocol::{EndpointError, HookID, Packet, SessionStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Caller-owned view and packet-flow store for interface frontends.
|
||||||
|
///
|
||||||
|
/// Generated leaves receive a mutable reference to this store during interface-aware
|
||||||
|
/// updates. They decide which leaf/session/procedure keys to touch, but the storage
|
||||||
|
/// itself stays with the renderer or application shell so protocol state remains
|
||||||
|
/// headless and reusable.
|
||||||
|
pub struct InterfaceStore {
|
||||||
|
next_sequence: u64,
|
||||||
|
now_ns: Option<u64>,
|
||||||
|
events: Vec<InterfaceEvent>,
|
||||||
|
sessions: BTreeMap<SessionKey, SessionView>,
|
||||||
|
procedures: BTreeMap<ProcedureKey, ProcedureView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterfaceStore {
|
||||||
|
/// Creates an empty caller-owned interface store.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next_sequence: 0,
|
||||||
|
now_ns: None,
|
||||||
|
events: Vec::new(),
|
||||||
|
sessions: BTreeMap::new(),
|
||||||
|
procedures: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the timestamp attached to later events.
|
||||||
|
///
|
||||||
|
/// The core crate stays `no_std`, so the caller supplies time from its runtime.
|
||||||
|
/// Passing `None` keeps event ordering without pretending the protocol owns a
|
||||||
|
/// clock.
|
||||||
|
pub fn set_now_ns(&mut self, now_ns: Option<u64>) {
|
||||||
|
self.now_ns = now_ns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the timestamp that will be attached to new events.
|
||||||
|
pub fn now_ns(&self) -> Option<u64> {
|
||||||
|
self.now_ns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all recorded events in insertion order.
|
||||||
|
pub fn events(&self) -> &[InterfaceEvent] {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all session views keyed by leaf, procedure, and hook id.
|
||||||
|
pub fn session_views(&self) -> &BTreeMap<SessionKey, SessionView> {
|
||||||
|
&self.sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all procedure views keyed by leaf and procedure id.
|
||||||
|
pub fn procedure_views(&self) -> &BTreeMap<ProcedureKey, ProcedureView> {
|
||||||
|
&self.procedures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns or creates the view for a hook-backed session.
|
||||||
|
pub fn session_view_mut(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
) -> &mut SessionView {
|
||||||
|
self.sessions
|
||||||
|
.entry(SessionKey {
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
})
|
||||||
|
.or_insert_with(SessionView::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns or creates the view for a one-shot procedure family.
|
||||||
|
pub fn procedure_view_mut(&mut self, leaf_id: u32, procedure_id: u32) -> &mut ProcedureView {
|
||||||
|
self.procedures
|
||||||
|
.entry(ProcedureKey {
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
})
|
||||||
|
.or_insert_with(ProcedureView::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a packet delivered to a generated leaf.
|
||||||
|
pub fn record_inbound(&mut self, leaf_id: u32, packet: &Packet) {
|
||||||
|
self.push_packet_event(
|
||||||
|
leaf_id,
|
||||||
|
packet,
|
||||||
|
InterfaceEventKind::Inbound {
|
||||||
|
packet: packet.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a packet was queued for an existing session inbox.
|
||||||
|
pub fn record_session_packet_queued(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
) {
|
||||||
|
self.push_session_event(
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
None,
|
||||||
|
InterfaceEventKind::SessionPacketQueued {
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records successful creation of a new session state.
|
||||||
|
pub fn record_session_created(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
) {
|
||||||
|
self.push_session_event(
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
Some(SessionViewStatus::Running),
|
||||||
|
InterfaceEventKind::SessionCreated {
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
started_ns,
|
||||||
|
finished_ns: self.now_ns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records rejection of a packet that could not create a session.
|
||||||
|
pub fn record_session_rejected(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
) {
|
||||||
|
self.push_session_event(
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
Some(SessionViewStatus::Rejected),
|
||||||
|
InterfaceEventKind::SessionRejected {
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
started_ns,
|
||||||
|
finished_ns: self.now_ns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records one session update tick.
|
||||||
|
pub fn record_session_update(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
status: SessionStatus,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
) {
|
||||||
|
self.push_session_event(
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
Some(SessionViewStatus::from_session_status(status)),
|
||||||
|
InterfaceEventKind::SessionUpdated {
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
status,
|
||||||
|
started_ns,
|
||||||
|
finished_ns: self.now_ns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records one procedure call.
|
||||||
|
pub fn record_procedure_call(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
started_ns: Option<u64>,
|
||||||
|
) {
|
||||||
|
self.push_procedure_event(
|
||||||
|
leaf_id,
|
||||||
|
procedure_id,
|
||||||
|
InterfaceEventKind::ProcedureCalled {
|
||||||
|
procedure_id,
|
||||||
|
hook_id,
|
||||||
|
started_ns,
|
||||||
|
finished_ns: self.now_ns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a packet emitted by leaf logic before route retry handling.
|
||||||
|
pub fn record_outbound_queued(&mut self, leaf_id: u32, packet: &Packet) {
|
||||||
|
self.push_packet_event(
|
||||||
|
leaf_id,
|
||||||
|
packet,
|
||||||
|
InterfaceEventKind::OutboundQueued {
|
||||||
|
packet: packet.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a route attempt for a queued outbound packet.
|
||||||
|
pub fn record_route_attempt(&mut self, leaf_id: u32, packet: &Packet) {
|
||||||
|
self.push_packet_event(
|
||||||
|
leaf_id,
|
||||||
|
packet,
|
||||||
|
InterfaceEventKind::RouteAttempt {
|
||||||
|
packet: packet.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a successful route attempt.
|
||||||
|
pub fn record_route_success(&mut self, leaf_id: u32, packet: &Packet) {
|
||||||
|
self.push_packet_event(
|
||||||
|
leaf_id,
|
||||||
|
packet,
|
||||||
|
InterfaceEventKind::RouteSuccess {
|
||||||
|
packet: packet.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a failed route attempt without removing the packet from retry state.
|
||||||
|
pub fn record_route_failure(&mut self, leaf_id: u32, packet: &Packet, error: EndpointError) {
|
||||||
|
self.push_packet_event(
|
||||||
|
leaf_id,
|
||||||
|
packet,
|
||||||
|
InterfaceEventKind::RouteFailure {
|
||||||
|
packet: packet.clone(),
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_packet_event(&mut self, leaf_id: u32, packet: &Packet, kind: InterfaceEventKind) {
|
||||||
|
let index = self.push_event(leaf_id, kind);
|
||||||
|
self.link_packet_event(leaf_id, packet, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_session_event(
|
||||||
|
&mut self,
|
||||||
|
leaf_id: u32,
|
||||||
|
procedure_id: u32,
|
||||||
|
hook_id: HookID,
|
||||||
|
status: Option<SessionViewStatus>,
|
||||||
|
kind: InterfaceEventKind,
|
||||||
|
) {
|
||||||
|
let index = self.push_event(leaf_id, kind);
|
||||||
|
let view = self.session_view_mut(leaf_id, procedure_id, hook_id);
|
||||||
|
|
||||||
|
if let Some(status) = status {
|
||||||
|
view.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.events.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_procedure_event(&mut self, leaf_id: u32, procedure_id: u32, kind: InterfaceEventKind) {
|
||||||
|
let index = self.push_event(leaf_id, kind);
|
||||||
|
self.procedure_view_mut(leaf_id, procedure_id)
|
||||||
|
.events
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_event(&mut self, leaf_id: u32, kind: InterfaceEventKind) -> usize {
|
||||||
|
let sequence = self.next_sequence;
|
||||||
|
self.next_sequence = self.next_sequence.wrapping_add(1);
|
||||||
|
let index = self.events.len();
|
||||||
|
|
||||||
|
self.events.push(InterfaceEvent {
|
||||||
|
sequence,
|
||||||
|
time_ns: self.now_ns,
|
||||||
|
leaf_id,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_packet_event(&mut self, leaf_id: u32, packet: &Packet, index: usize) {
|
||||||
|
self.session_view_mut(leaf_id, packet.procedure_id, packet.hook_id)
|
||||||
|
.events
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InterfaceStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::protocol::SessionStatus;
|
||||||
|
|
||||||
|
/// Caller-owned render view for one hook-backed session.
|
||||||
|
pub struct SessionView {
|
||||||
|
/// Latest known lifecycle status.
|
||||||
|
pub status: SessionViewStatus,
|
||||||
|
|
||||||
|
/// Indices into the store's append-only event list.
|
||||||
|
pub events: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionView {
|
||||||
|
/// Creates an empty pending view.
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
status: SessionViewStatus::Pending,
|
||||||
|
events: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-owned render view for one one-shot procedure family.
|
||||||
|
pub struct ProcedureView {
|
||||||
|
/// Indices into the store's append-only event list.
|
||||||
|
pub events: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcedureView {
|
||||||
|
/// Creates an empty procedure view.
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self { events: Vec::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface lifecycle state for one session view.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SessionViewStatus {
|
||||||
|
/// The view exists because packets referenced it, but no live state exists yet.
|
||||||
|
Pending,
|
||||||
|
|
||||||
|
/// The session is active.
|
||||||
|
Running,
|
||||||
|
|
||||||
|
/// The session is winding down but may still emit packets.
|
||||||
|
Closing,
|
||||||
|
|
||||||
|
/// The session reported that application work is complete.
|
||||||
|
Closed,
|
||||||
|
|
||||||
|
/// The leaf rejected the packet that would have created this session.
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionViewStatus {
|
||||||
|
/// Converts the protocol session status into a renderable status.
|
||||||
|
pub(crate) fn from_session_status(status: SessionStatus) -> Self {
|
||||||
|
match status {
|
||||||
|
SessionStatus::Running => Self::Running,
|
||||||
|
SessionStatus::Closing => Self::Closing,
|
||||||
|
SessionStatus::Closed => Self::Closed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-50
@@ -6,62 +6,17 @@
|
|||||||
//! ## Architecture
|
//! ## Architecture
|
||||||
//!
|
//!
|
||||||
//! - [`protocol`] - Wire types, framing, stateless validation, and routing/runtime.
|
//! - [`protocol`] - Wire types, framing, stateless validation, and routing/runtime.
|
||||||
|
//! - [`interface`] - Typed control surfaces used by UI adapters and control leaves.
|
||||||
//!
|
//!
|
||||||
//! The library requires `alloc` for path and payload management.
|
//! The library requires `alloc` for path and payload management.
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
pub extern crate alloc;
|
pub extern crate alloc;
|
||||||
// Re-export derive macros against a stable `::unshell` path, including when the
|
|
||||||
// macros are used inside this crate's own examples and tests.
|
|
||||||
#[allow(unused_extern_crates)]
|
|
||||||
extern crate self as unshell;
|
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod interface;
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
/// Re-export the protocol crate behind the historical `unshell::protocol` path so
|
// pub use hash::hash;
|
||||||
/// proc-macro output and downstream code do not need a second migration.
|
|
||||||
pub use unshell_protocol as protocol;
|
|
||||||
|
|
||||||
/// Re-export the leaf library crate behind the historical `unshell::leaves` path
|
|
||||||
pub use unshell_leaves as leaves;
|
|
||||||
|
|
||||||
pub use unshell_macros::{Procedure, leaf, procedures};
|
|
||||||
|
|
||||||
/// Creates a root-assumed endpoint from one local identifier plus any number of leaf hosts.
|
|
||||||
///
|
|
||||||
/// What it is: a convenience macro that builds a `ProtocolEndpoint` whose protocol path starts at
|
|
||||||
/// root, with no parent or children, and whose leaf inventory is inferred from the supplied host
|
|
||||||
/// values.
|
|
||||||
///
|
|
||||||
/// Why it exists: the common bootstrap case should not require callers to manually construct an
|
|
||||||
/// empty path, `Vec<ChildRoute>`, and a `Vec<LeafSpec>` when they already have leaf host values.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::{create_endpoint, leaf};
|
|
||||||
/// use unshell::protocol::tree::Endpoint;
|
|
||||||
///
|
|
||||||
/// #[derive(Default)]
|
|
||||||
/// struct DemoLeaf;
|
|
||||||
///
|
|
||||||
/// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)]
|
|
||||||
/// struct Demo;
|
|
||||||
///
|
|
||||||
/// let endpoint = create_endpoint!("demo", DemoLeaf::default());
|
|
||||||
/// assert!(endpoint.path().is_empty());
|
|
||||||
/// assert_eq!(endpoint.local_id(), Some("demo"));
|
|
||||||
/// ```
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! create_endpoint {
|
|
||||||
($id:expr $(, $leaf:expr )* $(,)?) => {{
|
|
||||||
let mut __unshell_leaf_specs = ::unshell::alloc::vec::Vec::new();
|
|
||||||
$(
|
|
||||||
let __unshell_leaf = $leaf;
|
|
||||||
__unshell_leaf_specs.push(::unshell::protocol::tree::leaf_spec_of(&__unshell_leaf));
|
|
||||||
)*
|
|
||||||
::unshell::protocol::tree::ProtocolEndpoint::root($id, __unshell_leaf_specs)
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
// pub use ush_obfuscate as obfuscate;
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
use crate::protocol::{Endpoint, EndpointError, EndpointName};
|
||||||
|
|
||||||
|
/// Compact identifier for one routed return channel.
|
||||||
|
///
|
||||||
|
/// Hook ids are local endpoint state, not globally unique session ids. A downward
|
||||||
|
/// packet with `end_hook = false` reserves the id at each endpoint it crosses so
|
||||||
|
/// later upward packets can prove that the route was paved by trusted downward
|
||||||
|
/// traffic first.
|
||||||
|
pub type HookID = u16;
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
/// Allocates a hook id that is not currently active on this endpoint.
|
||||||
|
///
|
||||||
|
/// The first id is still deterministic (`0`) for the protocol tests, but the
|
||||||
|
/// allocator now skips active hooks so long-lived streams cannot accidentally
|
||||||
|
/// reuse an id before the previous route has closed. If every `u16` id is active
|
||||||
|
/// the function panics; that is a hard local resource exhaustion condition, not a
|
||||||
|
/// recoverable packet error.
|
||||||
|
///
|
||||||
|
/// TODO: Reevaluate this method of allocation checking. It can be quite slow
|
||||||
|
pub fn allocate_hook_id(&mut self) -> HookID {
|
||||||
|
for _ in 0..=HookID::MAX {
|
||||||
|
let candidate = self.last_hook.next();
|
||||||
|
|
||||||
|
if !self.hooks.contains_key(&candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid a panic message here: this crate is optimized for small binaries,
|
||||||
|
// and exhausting every `u16` hook id is unrecoverable local state corruption.
|
||||||
|
panic!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backwards-compatible name for [`Self::allocate_hook_id`].
|
||||||
|
///
|
||||||
|
/// Existing leaves and tests still call `get_hook_id`; new code should prefer
|
||||||
|
/// `allocate_hook_id` because it describes the reservation semantics more clearly.
|
||||||
|
pub fn get_hook_id(&mut self) -> HookID {
|
||||||
|
self.allocate_hook_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly records that `peer` may use `hook_id` as this endpoint's return channel.
|
||||||
|
///
|
||||||
|
/// Routing calls this automatically for successful downward packets whose
|
||||||
|
/// `end_hook` flag is false. The public method exists for trusted local setup and
|
||||||
|
/// tests; ordinary leaf procedures should usually let packet routing pave hooks
|
||||||
|
/// instead of mutating hook state by hand.
|
||||||
|
pub fn accept_hook(&mut self, hook_id: HookID, peer: u32) -> Option<u32> {
|
||||||
|
self.hooks.insert(hook_id, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when `hook_id` is currently active.
|
||||||
|
pub fn has_hook(&self, hook_id: HookID) -> bool {
|
||||||
|
self.hooks.contains_key(&hook_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the adjacent peer currently associated with `hook_id`.
|
||||||
|
///
|
||||||
|
/// The peer is the next endpoint expected to participate in the return channel:
|
||||||
|
/// a child for downward calls that will reply upward, or a parent for a local
|
||||||
|
/// callee that will emit an upward response.
|
||||||
|
pub fn hook_peer(&self, hook_id: HookID) -> Option<u32> {
|
||||||
|
self.hooks.get(&hook_id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of active hooks on this endpoint.
|
||||||
|
pub fn hook_count(&self) -> usize {
|
||||||
|
self.hooks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally forgets a hook without sending protocol traffic.
|
||||||
|
///
|
||||||
|
/// Graceful shutdown should use a packet with `end_hook = true` so every endpoint
|
||||||
|
/// along the route cleans up after successful delivery. This method is for local
|
||||||
|
/// emergency cleanup such as a crashed PTY process, a timed-out stream, or a lost
|
||||||
|
/// transport where no final packet can be delivered.
|
||||||
|
pub fn forget_hook(&mut self, hook_id: HookID) -> bool {
|
||||||
|
self.close_hook(hook_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates that `actual_peer` is the peer allowed to use `hook_id`.
|
||||||
|
pub(crate) fn ensure_hook_peer(
|
||||||
|
&self,
|
||||||
|
hook_id: HookID,
|
||||||
|
actual_peer: EndpointName,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
let expected_peer = self
|
||||||
|
.hook_peer(hook_id)
|
||||||
|
.ok_or(EndpointError::UnknownHook { hook_id })?;
|
||||||
|
|
||||||
|
if expected_peer == actual_peer {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(EndpointError::HookPeerMismatch {
|
||||||
|
hook_id,
|
||||||
|
expected_peer,
|
||||||
|
actual_peer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or refreshes `hook_id` for the adjacent `peer` after downward routing succeeds.
|
||||||
|
pub(crate) fn open_hook(&mut self, hook_id: HookID, peer: EndpointName) {
|
||||||
|
self.hooks.insert(hook_id, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes `hook_id` and reports whether it existed.
|
||||||
|
pub(crate) fn close_hook(&mut self, hook_id: HookID) -> bool {
|
||||||
|
self.hooks.remove(&hook_id).is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
mod hooks;
|
||||||
|
mod routing;
|
||||||
|
|
||||||
|
pub use hooks::HookID;
|
||||||
|
|
||||||
|
use alloc::{boxed::Box, vec::Vec};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
crypto::Counter,
|
||||||
|
protocol::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Endpoint {
|
||||||
|
// This endpoint's identifier
|
||||||
|
pub id: u32,
|
||||||
|
|
||||||
|
// A counter that creates unique hook IDs.
|
||||||
|
pub(crate) last_hook: Counter,
|
||||||
|
|
||||||
|
// Absolute path for this node. Must be set by some leaf
|
||||||
|
pub path: Path,
|
||||||
|
pub leaves: Vec<Box<dyn Leaf>>,
|
||||||
|
|
||||||
|
// Map of connections so that we can know what is connected
|
||||||
|
// and which endpoints are authorities
|
||||||
|
pub connections: ConnectionSet,
|
||||||
|
|
||||||
|
// Local list of hooks.
|
||||||
|
pub(crate) hooks: HookMap,
|
||||||
|
|
||||||
|
// Map of endpoints to packet queues
|
||||||
|
pub(crate) inbound: RouteMap,
|
||||||
|
pub(crate) outbound: RouteMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
pub fn new(id: u32, leaves: Vec<Box<dyn Leaf>>) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
// Init the hook at 0, which will increment
|
||||||
|
last_hook: Counter::new(),
|
||||||
|
|
||||||
|
// Set the current path as an empty vec
|
||||||
|
path: Vec::new(),
|
||||||
|
leaves,
|
||||||
|
hooks: HookMap::new(),
|
||||||
|
connections: ConnectionSet::new(),
|
||||||
|
inbound: RouteMap::new(),
|
||||||
|
outbound: RouteMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass the endpoint state into all of the leaves
|
||||||
|
pub fn update(&mut self) {
|
||||||
|
// Grab the leaf vec temporarily so that we can iter over self
|
||||||
|
// Apparently this only swaps out pointers
|
||||||
|
let mut leaves = core::mem::take(&mut self.leaves);
|
||||||
|
|
||||||
|
for leaf in leaves.iter_mut() {
|
||||||
|
leaf.update(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.leaves = leaves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a function over all inbound packets with some ID then clear it.
|
||||||
|
pub fn take_inbound_clear<F>(&mut self, path: u32, f: F)
|
||||||
|
where
|
||||||
|
F: FnMut(&Packet),
|
||||||
|
{
|
||||||
|
Self::take_clear(path, f, &mut self.inbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain inbound packets for `path` that match `predicate` and preserve the rest.
|
||||||
|
///
|
||||||
|
/// Generated leaf dispatch uses this instead of [`Self::take_inbound_clear`] so
|
||||||
|
/// one leaf can consume only its procedure or session packets without stealing
|
||||||
|
/// traffic intended for another leaf. Matching packets are passed by value because
|
||||||
|
/// most handlers need to move payload bytes into application state; unmatched
|
||||||
|
/// packets are reinserted in their original FIFO order.
|
||||||
|
pub fn take_inbound_matching<P, F>(&mut self, path: u32, mut predicate: P, mut f: F)
|
||||||
|
where
|
||||||
|
P: FnMut(&Packet) -> bool,
|
||||||
|
F: FnMut(Packet),
|
||||||
|
{
|
||||||
|
let Some(mut queue) = self.inbound.remove(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut unmatched = Vec::new();
|
||||||
|
|
||||||
|
while let Some(packet) = queue.pop_front() {
|
||||||
|
if predicate(&packet) {
|
||||||
|
f(packet);
|
||||||
|
} else {
|
||||||
|
unmatched.push(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !unmatched.is_empty() {
|
||||||
|
self.inbound.entry(path).or_default().extend(unmatched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a function over all outbound packets with some ID then clear it.
|
||||||
|
pub fn take_outbound_clear<F>(&mut self, path: u32, f: F)
|
||||||
|
where
|
||||||
|
F: FnMut(&Packet),
|
||||||
|
{
|
||||||
|
Self::take_clear(path, f, &mut self.outbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_clear<F>(path: u32, mut f: F, queue: &mut RouteMap)
|
||||||
|
where
|
||||||
|
F: FnMut(&Packet),
|
||||||
|
{
|
||||||
|
if let Some(queue) = queue.get_mut(&path) {
|
||||||
|
for packet in queue.iter() {
|
||||||
|
f(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_leaves<F>(&mut self) -> core::slice::IterMut<'_, Box<dyn Leaf + 'static>>
|
||||||
|
where
|
||||||
|
F: FnMut(&Packet),
|
||||||
|
{
|
||||||
|
self.leaves.iter_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
use crate::protocol::{Endpoint, EndpointError, Packet, RouteDirection};
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
/// Register an inbound packet from legacy trusted code.
|
||||||
|
///
|
||||||
|
/// Transports should prefer [`Self::add_inbound_from`] because peer-bound hook
|
||||||
|
/// validation needs to know which adjacent endpoint supplied the bytes. This
|
||||||
|
/// method keeps the old trusted in-process path small: it derives path direction,
|
||||||
|
/// forwards or delivers the packet, and only checks that upward hooks exist.
|
||||||
|
pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> {
|
||||||
|
self.route_trusted_packet(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an inbound packet received from `remote_id` and route it locally.
|
||||||
|
///
|
||||||
|
/// Packets from a parent are downward traffic and pave return hooks when
|
||||||
|
/// `end_hook` is false. Packets from a child are upward traffic and must match an
|
||||||
|
/// already-paved hook for that exact child before they can move farther upward.
|
||||||
|
pub fn add_inbound_from(
|
||||||
|
&mut self,
|
||||||
|
remote_id: u32,
|
||||||
|
packet: Packet,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
self.ensure_path_is_set()?;
|
||||||
|
|
||||||
|
let inbound_direction = self.inbound_direction_from_peer(remote_id)?;
|
||||||
|
|
||||||
|
if packet.path == self.path {
|
||||||
|
return match inbound_direction {
|
||||||
|
RouteDirection::Downward => self.deliver_local_downward(packet, remote_id),
|
||||||
|
RouteDirection::Upward => self.deliver_local_upward(packet, remote_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if packet.path.starts_with(&self.path) {
|
||||||
|
self.ensure_inbound_direction(remote_id, inbound_direction, RouteDirection::Downward)?;
|
||||||
|
let next_hop = self.immediate_child_hop(&packet)?;
|
||||||
|
return self.route_downward(packet, next_hop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.path.starts_with(&packet.path) {
|
||||||
|
self.ensure_inbound_direction(remote_id, inbound_direction, RouteDirection::Upward)?;
|
||||||
|
let next_hop = self.parent_hop()?;
|
||||||
|
return self.route_upward(packet, next_hop, Some(remote_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(EndpointError::DestinationOutsideLocalTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an outbound packet produced locally and route it to the next queue.
|
||||||
|
pub fn add_outbound(&mut self, packet: Packet) -> Result<(), EndpointError> {
|
||||||
|
self.ensure_path_is_set()?;
|
||||||
|
|
||||||
|
if packet.path == self.path {
|
||||||
|
return self.deliver_local(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if packet.path.starts_with(&self.path) {
|
||||||
|
let next_hop = self.immediate_child_hop(&packet)?;
|
||||||
|
return self.route_downward(packet, next_hop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.path.starts_with(&packet.path) {
|
||||||
|
let next_hop = self.parent_hop()?;
|
||||||
|
return self.route_upward(packet, next_hop, Some(next_hop));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(EndpointError::DestinationOutsideLocalTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes a trusted packet without transport-peer direction metadata.
|
||||||
|
///
|
||||||
|
/// This intentionally does not create local hooks on local delivery because the
|
||||||
|
/// endpoint cannot know whether the packet came from a parent or child. Transit
|
||||||
|
/// routing still maintains hook state where path direction is unambiguous.
|
||||||
|
fn route_trusted_packet(&mut self, packet: Packet) -> Result<(), EndpointError> {
|
||||||
|
self.ensure_path_is_set()?;
|
||||||
|
|
||||||
|
if packet.path == self.path {
|
||||||
|
return self.deliver_local(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if packet.path.starts_with(&self.path) {
|
||||||
|
let next_hop = self.immediate_child_hop(&packet)?;
|
||||||
|
return self.route_downward(packet, next_hop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.path.starts_with(&packet.path) {
|
||||||
|
let next_hop = self.parent_hop()?;
|
||||||
|
return self.route_upward(packet, next_hop, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(EndpointError::DestinationOutsideLocalTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delivers a packet to local leaves without changing hook state.
|
||||||
|
fn deliver_local(&mut self, packet: Packet) -> Result<(), EndpointError> {
|
||||||
|
let local_id = self.local_id()?;
|
||||||
|
self.inbound.entry(local_id).or_default().push_back(packet);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delivers parent-originated traffic locally and applies downward hook policy.
|
||||||
|
fn deliver_local_downward(&mut self, packet: Packet, peer: u32) -> Result<(), EndpointError> {
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let end_hook = packet.end_hook;
|
||||||
|
|
||||||
|
self.deliver_local(packet)?;
|
||||||
|
self.apply_downward_hook_lifecycle(hook_id, end_hook, peer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delivers child-originated traffic locally after validating its return hook.
|
||||||
|
fn deliver_local_upward(&mut self, packet: Packet, peer: u32) -> Result<(), EndpointError> {
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let end_hook = packet.end_hook;
|
||||||
|
|
||||||
|
self.ensure_hook_peer(hook_id, peer)?;
|
||||||
|
self.deliver_local(packet)?;
|
||||||
|
self.apply_upward_hook_lifecycle(hook_id, end_hook);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards a packet to a child and applies downward hook lifecycle rules.
|
||||||
|
fn route_downward(&mut self, packet: Packet, next_hop: u32) -> Result<(), EndpointError> {
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let end_hook = packet.end_hook;
|
||||||
|
|
||||||
|
self.ensure_registered_connection(next_hop, RouteDirection::Downward)?;
|
||||||
|
self.outbound.entry(next_hop).or_default().push_back(packet);
|
||||||
|
self.apply_downward_hook_lifecycle(hook_id, end_hook, next_hop);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards a packet toward the parent after validating hook state.
|
||||||
|
///
|
||||||
|
/// `actual_peer` is `None` only for legacy trusted inbound routing where the
|
||||||
|
/// transport source is unknown; in that mode the endpoint can check that a hook
|
||||||
|
/// exists but cannot enforce peer ownership.
|
||||||
|
fn route_upward(
|
||||||
|
&mut self,
|
||||||
|
packet: Packet,
|
||||||
|
next_hop: u32,
|
||||||
|
actual_peer: Option<u32>,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let end_hook = packet.end_hook;
|
||||||
|
|
||||||
|
self.ensure_upward_hook_peer(hook_id, actual_peer)?;
|
||||||
|
self.ensure_registered_connection(next_hop, RouteDirection::Upward)?;
|
||||||
|
self.outbound.entry(next_hop).or_default().push_back(packet);
|
||||||
|
self.apply_upward_hook_lifecycle(hook_id, end_hook);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns this endpoint's final path segment for local queueing.
|
||||||
|
fn local_id(&self) -> Result<u32, EndpointError> {
|
||||||
|
self.path
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.ok_or(EndpointError::EndpointPathUnset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the child that should receive a downward packet next.
|
||||||
|
fn immediate_child_hop(&self, packet: &Packet) -> Result<u32, EndpointError> {
|
||||||
|
packet
|
||||||
|
.path
|
||||||
|
.get(self.path.len())
|
||||||
|
.copied()
|
||||||
|
.ok_or(EndpointError::DestinationOutsideLocalTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the direct parent next hop for upward routing.
|
||||||
|
fn parent_hop(&self) -> Result<u32, EndpointError> {
|
||||||
|
let parent_index = self
|
||||||
|
.path
|
||||||
|
.len()
|
||||||
|
.checked_sub(2)
|
||||||
|
.ok_or(EndpointError::MissingParentRoute)?;
|
||||||
|
|
||||||
|
Ok(self.path[parent_index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject routing before path-relative decisions when no absolute path is known.
|
||||||
|
///
|
||||||
|
/// This preserves the current runtime sentinel where an empty path means the
|
||||||
|
/// endpoint has not been attached to the tree yet.
|
||||||
|
fn ensure_path_is_set(&self) -> Result<(), EndpointError> {
|
||||||
|
if self.path.is_empty() {
|
||||||
|
Err(EndpointError::EndpointPathUnset)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives packet direction from a registered inbound adjacent peer.
|
||||||
|
fn inbound_direction_from_peer(&self, remote_id: u32) -> Result<RouteDirection, EndpointError> {
|
||||||
|
let is_upstream = self.connections.contains(&(remote_id, true));
|
||||||
|
let is_downstream = self.connections.contains(&(remote_id, false));
|
||||||
|
|
||||||
|
match (is_upstream, is_downstream) {
|
||||||
|
(true, false) => Ok(RouteDirection::Downward),
|
||||||
|
(false, true) => Ok(RouteDirection::Upward),
|
||||||
|
(false, false) => Err(EndpointError::UnknownConnection { remote_id }),
|
||||||
|
(true, true) => Err(EndpointError::AmbiguousConnection { remote_id }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects inbound packets whose path-derived direction contradicts the connection.
|
||||||
|
fn ensure_inbound_direction(
|
||||||
|
&self,
|
||||||
|
remote_id: u32,
|
||||||
|
expected: RouteDirection,
|
||||||
|
actual: RouteDirection,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
if expected == actual {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(EndpointError::InboundDirectionMismatch {
|
||||||
|
remote_id,
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the derived adjacent endpoint is registered in this direction.
|
||||||
|
///
|
||||||
|
/// The current connection table stores direction as a boolean. Keeping the bool
|
||||||
|
/// conversion here confines that legacy representation to one place in routing.
|
||||||
|
fn ensure_registered_connection(
|
||||||
|
&self,
|
||||||
|
next_hop: u32,
|
||||||
|
direction: RouteDirection,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
let is_upward = matches!(direction, RouteDirection::Upward);
|
||||||
|
|
||||||
|
if self.connections.contains(&(next_hop, is_upward)) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(EndpointError::MissingConnection {
|
||||||
|
next_hop,
|
||||||
|
direction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates hook state for upward routing.
|
||||||
|
fn ensure_upward_hook_peer(
|
||||||
|
&self,
|
||||||
|
hook_id: u16,
|
||||||
|
actual_peer: Option<u32>,
|
||||||
|
) -> Result<(), EndpointError> {
|
||||||
|
if let Some(actual_peer) = actual_peer {
|
||||||
|
self.ensure_hook_peer(hook_id, actual_peer)
|
||||||
|
} else if self.has_hook(hook_id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(EndpointError::UnknownHook { hook_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies hook state for successfully routed downward packets.
|
||||||
|
fn apply_downward_hook_lifecycle(&mut self, hook_id: u16, end_hook: bool, peer: u32) {
|
||||||
|
if end_hook {
|
||||||
|
self.close_hook(hook_id);
|
||||||
|
} else {
|
||||||
|
self.open_hook(hook_id, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies hook cleanup for successfully routed upward final packets.
|
||||||
|
fn apply_upward_hook_lifecycle(&mut self, hook_id: u16, end_hook: bool) {
|
||||||
|
if end_hook {
|
||||||
|
self.close_hook(hook_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/// Direction across the next local routing boundary.
|
||||||
|
///
|
||||||
|
/// The endpoint derives this from its own absolute path and the packet's
|
||||||
|
/// destination path. Packets are never trusted to declare their direction because
|
||||||
|
/// that would let an untrusted peer spoof the local routing boundary.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RouteDirection {
|
||||||
|
/// The packet moves toward this endpoint's direct parent.
|
||||||
|
Upward,
|
||||||
|
|
||||||
|
/// The packet moves toward one of this endpoint's direct children.
|
||||||
|
Downward,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level endpoint failure for packet conversion and local routing.
|
||||||
|
///
|
||||||
|
/// These are local processing failures, not protocol fault packets. A transport or
|
||||||
|
/// leaf may choose to drop the packet, log it, or translate it into a higher-level
|
||||||
|
/// fault depending on where the packet came from. Route variants stay flat so the
|
||||||
|
/// hot route path does not need a second nested enum just to explain the failure.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EndpointError {
|
||||||
|
/// This endpoint cannot route because its absolute path has not been assigned.
|
||||||
|
///
|
||||||
|
/// The current runtime uses an empty path as "not initialized". If the protocol
|
||||||
|
/// later supports an empty root path, route initialization should become an
|
||||||
|
/// explicit flag instead of being inferred from `path.is_empty()`.
|
||||||
|
EndpointPathUnset,
|
||||||
|
|
||||||
|
/// The packet destination is not local, below this endpoint, or above this endpoint.
|
||||||
|
///
|
||||||
|
/// This catches sideways or forged paths, for example local `/a/b` receiving a
|
||||||
|
/// packet addressed to `/a/c`.
|
||||||
|
DestinationOutsideLocalTree,
|
||||||
|
|
||||||
|
/// A route points upward, but this endpoint has no parent segment to forward to.
|
||||||
|
///
|
||||||
|
/// This means the path topology is internally inconsistent for upward routing.
|
||||||
|
MissingParentRoute,
|
||||||
|
|
||||||
|
/// The packet needs a registered connection for the computed next hop, but none exists.
|
||||||
|
///
|
||||||
|
/// Route derivation succeeded. Delivery fails only because the local connection
|
||||||
|
/// table does not contain the adjacent endpoint in the required direction.
|
||||||
|
MissingConnection {
|
||||||
|
/// Adjacent endpoint that should receive the packet next.
|
||||||
|
next_hop: u32,
|
||||||
|
|
||||||
|
/// Direction that the local connection must be registered for.
|
||||||
|
direction: RouteDirection,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Inbound transport bytes arrived from an endpoint that is not registered locally.
|
||||||
|
///
|
||||||
|
/// Direction-aware routing needs to know whether the remote endpoint is the
|
||||||
|
/// parent or a child before it can decide whether local delivery is downward or
|
||||||
|
/// upward traffic. Unknown peers are rejected before hook state can be mutated.
|
||||||
|
UnknownConnection {
|
||||||
|
/// Adjacent endpoint that supplied the inbound packet.
|
||||||
|
remote_id: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The same adjacent endpoint is registered as both parent and child.
|
||||||
|
///
|
||||||
|
/// The legacy connection table stores direction as a boolean. Both entries being
|
||||||
|
/// present would make inbound hook policy ambiguous, so the endpoint refuses to
|
||||||
|
/// route the packet until the connection state is made unambiguous.
|
||||||
|
AmbiguousConnection {
|
||||||
|
/// Adjacent endpoint whose direction cannot be inferred.
|
||||||
|
remote_id: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// An inbound packet tried to move in the opposite direction from its connection.
|
||||||
|
///
|
||||||
|
/// A parent/upstream peer may send packets downward, while a child/downstream
|
||||||
|
/// peer may send packets upward. This prevents a child from using its transport
|
||||||
|
/// link to forge downward traffic to siblings or descendants.
|
||||||
|
InboundDirectionMismatch {
|
||||||
|
/// Adjacent endpoint that supplied the inbound packet.
|
||||||
|
remote_id: u32,
|
||||||
|
|
||||||
|
/// Direction allowed by the registered connection.
|
||||||
|
expected: RouteDirection,
|
||||||
|
|
||||||
|
/// Direction implied by the packet destination path.
|
||||||
|
actual: RouteDirection,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The packet is trying to move upward without known hook state.
|
||||||
|
///
|
||||||
|
/// Upward hook traffic is gated by local hook state so a peer cannot forge a
|
||||||
|
/// return path just by choosing an ancestor destination.
|
||||||
|
UnknownHook {
|
||||||
|
/// Hook id claimed by the upward packet.
|
||||||
|
hook_id: u16,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The hook exists, but it is registered for a different adjacent peer.
|
||||||
|
///
|
||||||
|
/// Hook state is peer-bound so one child cannot reuse another child's paved
|
||||||
|
/// return channel. For locally generated upward traffic, `actual_peer` is the
|
||||||
|
/// parent next hop; for inbound upward traffic, it is the child that supplied the
|
||||||
|
/// frame.
|
||||||
|
HookPeerMismatch {
|
||||||
|
/// Hook id claimed by the upward packet.
|
||||||
|
hook_id: u16,
|
||||||
|
|
||||||
|
/// Adjacent peer recorded when the hook was paved.
|
||||||
|
expected_peer: u32,
|
||||||
|
|
||||||
|
/// Adjacent peer trying to use the hook now.
|
||||||
|
actual_peer: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A packet could not be converted into bytes for transport.
|
||||||
|
///
|
||||||
|
/// Endpoint-level code that drains outbound queues often wants one error type
|
||||||
|
/// for both routing and framing. Keeping the source error preserves the exact
|
||||||
|
/// packet-size invariant that failed.
|
||||||
|
PacketSerialize {
|
||||||
|
/// Exact packet serialization failure.
|
||||||
|
source: SerializeError,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Incoming bytes could not be parsed into a packet.
|
||||||
|
///
|
||||||
|
/// This represents a frame rejection before routing begins. The source error is
|
||||||
|
/// retained so callers can distinguish truncation from malformed body fields.
|
||||||
|
PacketDeserialize {
|
||||||
|
/// Exact packet deserialization failure.
|
||||||
|
source: DeserializeError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced while converting a [`Packet`] into its wire representation.
|
||||||
|
///
|
||||||
|
/// These failures are size-bound checks rather than transport errors. They protect
|
||||||
|
/// the length fields in the frame from integer overflow or values that cannot be
|
||||||
|
/// represented by the protocol's current `u32` length fields.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SerializeError {
|
||||||
|
/// The packet path contains more bytes than the frame length field can represent.
|
||||||
|
PathTooLarge,
|
||||||
|
|
||||||
|
/// The body section is too large to encode in a `u32` length field.
|
||||||
|
BodyTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced while parsing a [`Packet`] from untrusted wire bytes.
|
||||||
|
///
|
||||||
|
/// Deserialization rejects partial or inconsistent frames before endpoint routing
|
||||||
|
/// sees them. Keeping these separate from route failures makes it clear whether a
|
||||||
|
/// packet failed before or after it became structured data.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeserializeError {
|
||||||
|
/// The buffer ended before the parser could read the required field.
|
||||||
|
BufferTooShort,
|
||||||
|
|
||||||
|
/// The advertised body length does not fit inside the provided buffer.
|
||||||
|
BodyLengthMismatch,
|
||||||
|
|
||||||
|
/// The path length overflowed while computing the path byte range.
|
||||||
|
PathTooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SerializeError> for EndpointError {
|
||||||
|
/// Wraps packet serialization failures for endpoint-level callers.
|
||||||
|
fn from(source: SerializeError) -> Self {
|
||||||
|
Self::PacketSerialize { source }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeError> for EndpointError {
|
||||||
|
/// Wraps packet deserialization failures for endpoint-level callers.
|
||||||
|
fn from(source: DeserializeError) -> Self {
|
||||||
|
Self::PacketDeserialize { source }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::protocol::Endpoint;
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
use crate::{interface::InterfaceStore, protocol::leaf_meta::LeafMeta};
|
||||||
|
|
||||||
|
/// Application extension point hosted by an [`Endpoint`].
|
||||||
|
///
|
||||||
|
/// A leaf owns product-specific state and reacts to packets that endpoint routing has
|
||||||
|
/// already delivered locally. The trait intentionally stays small so handwritten
|
||||||
|
/// leaves, generated leaves, and test leaves can all share the same endpoint loop.
|
||||||
|
pub trait Leaf {
|
||||||
|
/// Returns the stable local identifier for this leaf implementation.
|
||||||
|
fn get_id(&self) -> u32;
|
||||||
|
|
||||||
|
/// Advances the leaf by one endpoint update tick.
|
||||||
|
///
|
||||||
|
/// Implementations normally drain matching inbound packets, mutate leaf-owned
|
||||||
|
/// state, then enqueue outbound packets with [`Endpoint::add_outbound`].
|
||||||
|
fn update(&mut self, _: &mut Endpoint);
|
||||||
|
|
||||||
|
/// Advances the leaf while recording caller-owned interface state.
|
||||||
|
///
|
||||||
|
/// Plain handwritten leaves can ignore the interface store and reuse their normal
|
||||||
|
/// update path. Generated leaves override this to route through the same template
|
||||||
|
/// helpers with packet flow logging enabled.
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn update_interface(&mut self, endpoint: &mut Endpoint, _: &mut InterfaceStore) {
|
||||||
|
self.update(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta::anonymous()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
fn render_ratatui(
|
||||||
|
&mut self,
|
||||||
|
_: &mut ratatui::Frame<'_>,
|
||||||
|
_: ratatui::layout::Rect,
|
||||||
|
_: &mut InterfaceStore,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
/// Human-facing metadata for a leaf implementation.
|
||||||
|
///
|
||||||
|
/// This is intentionally static text plus an allocated author list. It is only used
|
||||||
|
/// by interface frontends and diagnostics, not by hot packet routing.
|
||||||
|
pub struct LeafMeta {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub identifier: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub authors: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeafMeta {
|
||||||
|
/// Builds metadata for leaves that have not opted into a richer interface label.
|
||||||
|
pub fn anonymous() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Unnamed Leaf",
|
||||||
|
identifier: "dev.unshell.unknown",
|
||||||
|
version: "v0",
|
||||||
|
authors: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/// Declares a generated leaf wrapper using a small template-like syntax.
|
||||||
|
///
|
||||||
|
/// The macro deliberately requires callers to name every generated session field. It
|
||||||
|
/// does not infer identifiers, inspect struct fields, or hide behavior inside a proc
|
||||||
|
/// macro. All real dispatch and retry behavior lives in normal Rust helpers.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! unshell_leaf {
|
||||||
|
(
|
||||||
|
$vis:vis leaf $Leaf:ident for $State:ty {
|
||||||
|
id: $id:expr,
|
||||||
|
meta: $meta:expr,
|
||||||
|
sessions { $( $session_field:ident : $Session:ty ),* $(,)? }
|
||||||
|
procedures { $( $procedure_field:ident : $Procedure:ty ),* $(,)? }
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
$vis struct $Leaf {
|
||||||
|
state: $State,
|
||||||
|
outbox: $crate::protocol::LeafOutbox,
|
||||||
|
$(
|
||||||
|
$session_field: $crate::protocol::SessionFamily<
|
||||||
|
<$Session as $crate::protocol::Session<$State>>::State,
|
||||||
|
>,
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $Leaf {
|
||||||
|
/// Creates the generated leaf wrapper around user-owned state.
|
||||||
|
pub fn new(state: $State) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
outbox: $crate::protocol::LeafOutbox::new(),
|
||||||
|
$(
|
||||||
|
$session_field: $crate::protocol::SessionFamily::new(),
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns immutable access to the user-owned leaf state.
|
||||||
|
pub fn state(&self) -> &$State {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns mutable access to the user-owned leaf state.
|
||||||
|
pub fn state_mut(&mut self) -> &mut $State {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of active session entries across all families.
|
||||||
|
pub fn active_session_count(&self) -> usize {
|
||||||
|
0usize $(+ self.$session_field.entries.len())*
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns queued packets owned by this generated leaf.
|
||||||
|
pub fn pending_packet_count(&self) -> usize {
|
||||||
|
let mut count = self.outbox.len();
|
||||||
|
$(
|
||||||
|
count += self.$session_field.pending_packet_count();
|
||||||
|
)*
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __unshell_packet_is_owned(packet: &$crate::protocol::Packet) -> bool {
|
||||||
|
false
|
||||||
|
$(
|
||||||
|
|| packet.procedure_id
|
||||||
|
== <$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID
|
||||||
|
)*
|
||||||
|
$(
|
||||||
|
|| packet.procedure_id
|
||||||
|
== <$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __unshell_update_inner(
|
||||||
|
&mut self,
|
||||||
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
|
mut interface: Option<&mut $crate::interface::InterfaceStore>,
|
||||||
|
) {
|
||||||
|
let leaf_id = $id;
|
||||||
|
self.__unshell_flush_all(endpoint, &mut interface);
|
||||||
|
|
||||||
|
let Some(local_id) = endpoint.path.last().copied() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut packets = $crate::alloc::vec::Vec::new();
|
||||||
|
endpoint.take_inbound_matching(
|
||||||
|
local_id,
|
||||||
|
Self::__unshell_packet_is_owned,
|
||||||
|
|packet| packets.push(packet),
|
||||||
|
);
|
||||||
|
|
||||||
|
for packet in packets {
|
||||||
|
self.__unshell_dispatch_packet(
|
||||||
|
endpoint,
|
||||||
|
packet,
|
||||||
|
&mut interface,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(
|
||||||
|
$crate::protocol::update_session_family::<$State, $Session>(
|
||||||
|
leaf_id,
|
||||||
|
&mut self.state,
|
||||||
|
&mut self.$session_field,
|
||||||
|
&mut interface,
|
||||||
|
);
|
||||||
|
)*
|
||||||
|
|
||||||
|
self.__unshell_flush_all(endpoint, &mut interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __unshell_dispatch_packet(
|
||||||
|
&mut self,
|
||||||
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
|
packet: $crate::protocol::Packet,
|
||||||
|
interface: &mut Option<&mut $crate::interface::InterfaceStore>,
|
||||||
|
) {
|
||||||
|
let leaf_id = $id;
|
||||||
|
|
||||||
|
$(
|
||||||
|
if packet.procedure_id
|
||||||
|
== <$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID
|
||||||
|
{
|
||||||
|
$crate::protocol::dispatch_session::<$State, $Session>(
|
||||||
|
leaf_id,
|
||||||
|
&mut self.state,
|
||||||
|
&mut self.$session_field,
|
||||||
|
packet,
|
||||||
|
&mut self.outbox,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
|
||||||
|
$(
|
||||||
|
if packet.procedure_id
|
||||||
|
== <$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID
|
||||||
|
{
|
||||||
|
let _ = stringify!($procedure_field);
|
||||||
|
$crate::protocol::dispatch_procedure::<$State, $Procedure>(
|
||||||
|
leaf_id,
|
||||||
|
&mut self.state,
|
||||||
|
endpoint,
|
||||||
|
packet,
|
||||||
|
&mut self.outbox,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
|
||||||
|
let _ = endpoint;
|
||||||
|
let _ = packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __unshell_flush_all(
|
||||||
|
&mut self,
|
||||||
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
|
interface: &mut Option<&mut $crate::interface::InterfaceStore>,
|
||||||
|
) {
|
||||||
|
let leaf_id = $id;
|
||||||
|
|
||||||
|
$crate::protocol::flush_leaf_outbox(
|
||||||
|
endpoint,
|
||||||
|
leaf_id,
|
||||||
|
&mut self.outbox,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
|
||||||
|
$(
|
||||||
|
$crate::protocol::flush_session_family::<$State, $Session>(
|
||||||
|
endpoint,
|
||||||
|
leaf_id,
|
||||||
|
&mut self.$session_field,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $crate::protocol::Leaf for $Leaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
$id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut $crate::protocol::Endpoint) {
|
||||||
|
self.__unshell_update_inner(endpoint, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn update_interface(
|
||||||
|
&mut self,
|
||||||
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
|
interface: &mut $crate::interface::InterfaceStore,
|
||||||
|
) {
|
||||||
|
self.__unshell_update_inner(endpoint, Some(interface));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> $crate::protocol::LeafMeta {
|
||||||
|
$meta
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
fn render_ratatui(
|
||||||
|
&mut self,
|
||||||
|
frame: &mut $crate::protocol::ratatui::Frame<'_>,
|
||||||
|
area: $crate::protocol::ratatui::layout::Rect,
|
||||||
|
interface: &mut $crate::interface::InterfaceStore,
|
||||||
|
) {
|
||||||
|
let leaf_id = $id;
|
||||||
|
|
||||||
|
$(
|
||||||
|
for entry in &mut self.$session_field.entries {
|
||||||
|
let view = interface.session_view_mut(
|
||||||
|
leaf_id,
|
||||||
|
<$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID,
|
||||||
|
entry.hook_id,
|
||||||
|
);
|
||||||
|
<$Session as $crate::protocol::Session<$State>>::render_ratatui(
|
||||||
|
&self.state,
|
||||||
|
&entry.state,
|
||||||
|
view,
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
|
||||||
|
$(
|
||||||
|
{
|
||||||
|
let _ = stringify!($procedure_field);
|
||||||
|
let view = interface.procedure_view_mut(
|
||||||
|
leaf_id,
|
||||||
|
<$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID,
|
||||||
|
);
|
||||||
|
<$Procedure as $crate::protocol::Procedure<$State>>::render_ratatui(
|
||||||
|
&self.state,
|
||||||
|
view,
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
mod endpoint;
|
||||||
|
mod error;
|
||||||
|
mod leaf;
|
||||||
|
mod leaf_meta;
|
||||||
|
mod leaf_template;
|
||||||
|
mod packet;
|
||||||
|
mod procedure;
|
||||||
|
mod runtime;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use crate::unshell_leaf;
|
||||||
|
pub use endpoint::{Endpoint, HookID};
|
||||||
|
pub use error::*;
|
||||||
|
pub use leaf::Leaf;
|
||||||
|
pub use leaf_meta::LeafMeta;
|
||||||
|
pub use packet::Packet;
|
||||||
|
pub use procedure::*;
|
||||||
|
pub use runtime::*;
|
||||||
|
pub use session::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
pub use ratatui;
|
||||||
|
|
||||||
|
// Various named types used for brevity
|
||||||
|
use alloc::{
|
||||||
|
collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque},
|
||||||
|
vec::Vec,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Path = Vec<u32>;
|
||||||
|
type EndpointName = u32;
|
||||||
|
type ConnectionSet = BTreeSet<(EndpointName, bool)>;
|
||||||
|
type HookMap = BTreeMap<HookID, EndpointName>;
|
||||||
|
pub type PacketQueue = VecDeque<Packet>;
|
||||||
|
type RouteMap = BTreeMap<EndpointName, PacketQueue>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
mod merkle_sync;
|
||||||
|
mod oneshot;
|
||||||
|
mod packet;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::protocol::{DeserializeError, SerializeError};
|
||||||
|
|
||||||
|
/// Fully decoded UnShell test packet.
|
||||||
|
///
|
||||||
|
/// The current protocol tests route only on hook id, hook end state, and absolute
|
||||||
|
/// path. `procedure_id` is therefore a compact numeric contract id instead of a
|
||||||
|
/// string label; application code can maintain its own id-to-name table outside the
|
||||||
|
/// hot packet path if it needs human-readable names.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Packet {
|
||||||
|
pub hook_id: u16,
|
||||||
|
pub end_hook: bool,
|
||||||
|
pub path: Vec<u32>,
|
||||||
|
pub procedure_id: u32,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Packet {
|
||||||
|
/// Serializes the packet into the crate's current little-endian frame format.
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
/// - fixed header: `hook_id: u16`, `flags: u8`, padding, `path_len: u32`
|
||||||
|
/// - path: `path_len` little-endian `u32` segments
|
||||||
|
/// - body: `body_len: u32`, `procedure_id: u32`, raw `data`
|
||||||
|
///
|
||||||
|
/// Keeping `procedure_id` fixed-width removes the old string length and UTF-8
|
||||||
|
/// validation path. That makes deserialization a single full-packet parse,
|
||||||
|
/// which matches how the endpoint mock transports actually consume packets.
|
||||||
|
pub fn serialize(&self) -> Result<Vec<u8>, SerializeError> {
|
||||||
|
let path_len = u32::try_from(self.path.len()).map_err(|_| SerializeError::PathTooLarge)?;
|
||||||
|
|
||||||
|
// body = fixed procedure_id field + data bytes
|
||||||
|
let body_payload_len = 4usize
|
||||||
|
.checked_add(self.data.len())
|
||||||
|
.ok_or(SerializeError::BodyTooLarge)?;
|
||||||
|
let body_len = u32::try_from(body_payload_len).map_err(|_| SerializeError::BodyTooLarge)?;
|
||||||
|
|
||||||
|
let path_bytes = self
|
||||||
|
.path
|
||||||
|
.len()
|
||||||
|
.checked_mul(4)
|
||||||
|
.ok_or(SerializeError::PathTooLarge)?;
|
||||||
|
let total = 8usize
|
||||||
|
.checked_add(path_bytes)
|
||||||
|
.and_then(|n| n.checked_add(4))
|
||||||
|
.and_then(|n| n.checked_add(body_payload_len))
|
||||||
|
.ok_or(SerializeError::BodyTooLarge)?;
|
||||||
|
let mut buf = Vec::with_capacity(total);
|
||||||
|
|
||||||
|
// ── header ────────────────────────────────────────────────────────────
|
||||||
|
let flags = self.end_hook as u8;
|
||||||
|
buf.extend_from_slice(&self.hook_id.to_le_bytes());
|
||||||
|
buf.push(flags);
|
||||||
|
buf.push(0u8); // padding
|
||||||
|
buf.extend_from_slice(&path_len.to_le_bytes());
|
||||||
|
for &segment in &self.path {
|
||||||
|
buf.extend_from_slice(&segment.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── body ──────────────────────────────────────────────────────────────
|
||||||
|
buf.extend_from_slice(&body_len.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&self.procedure_id.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&self.data);
|
||||||
|
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserializes a full packet from untrusted transport bytes.
|
||||||
|
///
|
||||||
|
/// This parser intentionally consumes the complete packet shape. The old
|
||||||
|
/// partial parse path was removed because current routing tests and mock
|
||||||
|
/// transports always deserialize before calling endpoint routing, so keeping a
|
||||||
|
/// borrowed header API only preserved unused unsafe casting complexity.
|
||||||
|
pub fn deserialize(buf: &[u8]) -> Result<Self, DeserializeError> {
|
||||||
|
// fixed prefix: hook_id (2) + flags (1) + padding (1) + path_len (4)
|
||||||
|
if buf.len() < 8 {
|
||||||
|
return Err(DeserializeError::BufferTooShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hook_id = u16::from_le_bytes([buf[0], buf[1]]);
|
||||||
|
let flags = buf[2];
|
||||||
|
let end_hook = flags & 0b0000_0001 != 0;
|
||||||
|
let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize;
|
||||||
|
|
||||||
|
let path_start = 8usize;
|
||||||
|
let path_end = path_start
|
||||||
|
.checked_add(path_len * 4)
|
||||||
|
.ok_or(DeserializeError::PathTooLong)?;
|
||||||
|
|
||||||
|
if buf.len() < path_end {
|
||||||
|
return Err(DeserializeError::BufferTooShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = Vec::with_capacity(path_len);
|
||||||
|
for chunk in buf[path_start..path_end].chunks_exact(4) {
|
||||||
|
path.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// body_len prefix
|
||||||
|
let body_buf = &buf[path_end..];
|
||||||
|
if body_buf.len() < 4 {
|
||||||
|
return Err(DeserializeError::BufferTooShort);
|
||||||
|
}
|
||||||
|
let body_len =
|
||||||
|
u32::from_le_bytes([body_buf[0], body_buf[1], body_buf[2], body_buf[3]]) as usize;
|
||||||
|
|
||||||
|
let body_end = 4usize
|
||||||
|
.checked_add(body_len)
|
||||||
|
.ok_or(DeserializeError::BodyLengthMismatch)?;
|
||||||
|
if body_buf.len() < body_end {
|
||||||
|
return Err(DeserializeError::BodyLengthMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// procedure_id + data
|
||||||
|
let inner = &body_buf[4..body_end];
|
||||||
|
if inner.len() < 4 {
|
||||||
|
return Err(DeserializeError::BufferTooShort);
|
||||||
|
}
|
||||||
|
let procedure_id = u32::from_le_bytes([inner[0], inner[1], inner[2], inner[3]]);
|
||||||
|
|
||||||
|
let data = inner[4..].to_vec();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
path,
|
||||||
|
procedure_id,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::protocol::{Endpoint, HookID, Packet, PacketQueue};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
use crate::interface::ProcedureView;
|
||||||
|
|
||||||
|
/// Contract implemented by one generated one-packet procedure handler.
|
||||||
|
///
|
||||||
|
/// Procedures are for stateless or short-lived operations such as ping, capabilities,
|
||||||
|
/// or health checks. Long-running conversations should use [`Session`] so final
|
||||||
|
/// packet cleanup and retries remain tied to hook state.
|
||||||
|
pub trait Procedure<L> {
|
||||||
|
/// Outer packet procedure id handled by this procedure.
|
||||||
|
const PROCEDURE_ID: u32;
|
||||||
|
|
||||||
|
/// Handles one packet and optionally queues response packets in `out`.
|
||||||
|
fn handle(leaf: &mut L, endpoint: &mut Endpoint, packet: Packet, out: &mut ProcedureOut);
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
fn render_ratatui(
|
||||||
|
_: &L,
|
||||||
|
_: &mut ProcedureView,
|
||||||
|
_: &mut ratatui::Frame<'_>,
|
||||||
|
_: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output accumulator passed to [`Procedure::handle`].
|
||||||
|
pub struct ProcedureOut {
|
||||||
|
hook_id: HookID,
|
||||||
|
reply_path: Vec<u32>,
|
||||||
|
procedure_id: u32,
|
||||||
|
outbox: PacketQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcedureOut {
|
||||||
|
/// Creates an empty procedure output queue.
|
||||||
|
pub fn new(hook_id: HookID, reply_path: Vec<u32>, procedure_id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
hook_id,
|
||||||
|
reply_path,
|
||||||
|
procedure_id,
|
||||||
|
outbox: PacketQueue::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the response path used by later [`Self::send`] calls.
|
||||||
|
pub fn set_reply_path(&mut self, reply_path: Vec<u32>) {
|
||||||
|
self.reply_path = reply_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues raw response data without closing the hook.
|
||||||
|
pub fn send(&mut self, data: &[u8]) {
|
||||||
|
self.send_with_end(data, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues raw response data that closes the hook after successful routing.
|
||||||
|
pub fn send_final(&mut self, data: &[u8]) {
|
||||||
|
self.send_with_end(data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the output accumulator and returns packets for generated retry logic.
|
||||||
|
pub fn into_packets(self) -> PacketQueue {
|
||||||
|
self.outbox
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_with_end(&mut self, data: &[u8], end_hook: bool) {
|
||||||
|
self.outbox.push_back(Packet {
|
||||||
|
hook_id: self.hook_id,
|
||||||
|
end_hook,
|
||||||
|
path: self.reply_path.clone(),
|
||||||
|
procedure_id: self.procedure_id,
|
||||||
|
data: data.to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
use crate::{
|
||||||
|
interface::InterfaceStore,
|
||||||
|
protocol::{
|
||||||
|
Endpoint, Packet, PacketQueue, Procedure, ProcedureOut, Session, SessionCtx, SessionEntry,
|
||||||
|
SessionFamily, SessionInit, SessionInitResult, SessionStatus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Retry queue shared by generated leaves.
|
||||||
|
///
|
||||||
|
/// Sessions already own per-hook outboxes. This leaf-level queue is for rejected
|
||||||
|
/// session initialization responses and one-shot procedures, both of which need the
|
||||||
|
/// same retry semantics as session output without becoming separate framework types.
|
||||||
|
pub struct LeafOutbox {
|
||||||
|
packets: PacketQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeafOutbox {
|
||||||
|
/// Creates an empty leaf-level outbox.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
packets: PacketQueue::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one packet to the retry queue.
|
||||||
|
pub fn push(&mut self, packet: Packet) {
|
||||||
|
self.packets.push_back(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds all packets from `packets` in FIFO order.
|
||||||
|
pub fn extend(&mut self, packets: PacketQueue) {
|
||||||
|
self.packets.extend(packets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of queued packets.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.packets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when the queue has no pending packets.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.packets.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LeafOutbox {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches one packet into a generated session family.
|
||||||
|
///
|
||||||
|
/// The macro picks `S` and the family field. This helper owns the boring details:
|
||||||
|
/// find the hook, initialize missing sessions, queue rejected responses, and update
|
||||||
|
/// interface state when a caller supplied one.
|
||||||
|
pub fn dispatch_session<L, S>(
|
||||||
|
leaf_id: u32,
|
||||||
|
leaf: &mut L,
|
||||||
|
family: &mut SessionFamily<S::State>,
|
||||||
|
packet: Packet,
|
||||||
|
outbox: &mut LeafOutbox,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) where
|
||||||
|
S: Session<L>,
|
||||||
|
{
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let procedure_id = S::PROCEDURE_ID;
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_inbound(leaf_id, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = family
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|entry| entry.hook_id == hook_id)
|
||||||
|
{
|
||||||
|
entry.inbox.push_back(packet);
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_session_packet_queued(leaf_id, procedure_id, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let started_ns = interface.as_ref().and_then(|store| store.now_ns());
|
||||||
|
let packet_path = packet.path.clone();
|
||||||
|
let mut init = SessionInit::new(hook_id, packet_path);
|
||||||
|
|
||||||
|
match S::init(leaf, packet, &mut init) {
|
||||||
|
SessionInitResult::Created(state) => {
|
||||||
|
family.entries.push(SessionEntry::new(hook_id, state));
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_session_created(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionInitResult::Rejected => {
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionInitResult::RejectedWith(packet) => {
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
|
store.record_outbound_queued(leaf_id, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox.push(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates every live session in one generated session family.
|
||||||
|
pub fn update_session_family<L, S>(
|
||||||
|
leaf_id: u32,
|
||||||
|
leaf: &mut L,
|
||||||
|
family: &mut SessionFamily<S::State>,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) where
|
||||||
|
S: Session<L>,
|
||||||
|
{
|
||||||
|
for entry in &mut family.entries {
|
||||||
|
if entry.closed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let started_ns = interface.as_ref().and_then(|store| store.now_ns());
|
||||||
|
let reply_path = S::reply_path(&entry.state).to_vec();
|
||||||
|
let mut ctx = SessionCtx::new(
|
||||||
|
entry.hook_id,
|
||||||
|
reply_path,
|
||||||
|
S::PROCEDURE_ID,
|
||||||
|
&mut entry.outbox,
|
||||||
|
);
|
||||||
|
let status = S::update(leaf, &mut entry.state, &mut entry.inbox, &mut ctx);
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_session_update(
|
||||||
|
leaf_id,
|
||||||
|
S::PROCEDURE_ID,
|
||||||
|
entry.hook_id,
|
||||||
|
status,
|
||||||
|
started_ns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(status, SessionStatus::Closed) {
|
||||||
|
entry.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches one packet into a generated one-shot procedure.
|
||||||
|
pub fn dispatch_procedure<L, P>(
|
||||||
|
leaf_id: u32,
|
||||||
|
leaf: &mut L,
|
||||||
|
endpoint: &mut Endpoint,
|
||||||
|
packet: Packet,
|
||||||
|
outbox: &mut LeafOutbox,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) where
|
||||||
|
P: Procedure<L>,
|
||||||
|
{
|
||||||
|
let started_ns = interface.as_ref().and_then(|store| store.now_ns());
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_inbound(leaf_id, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hook_id = packet.hook_id;
|
||||||
|
let mut procedure_out =
|
||||||
|
ProcedureOut::new(hook_id, parent_reply_path(endpoint), P::PROCEDURE_ID);
|
||||||
|
|
||||||
|
P::handle(leaf, endpoint, packet, &mut procedure_out);
|
||||||
|
|
||||||
|
let packets = procedure_out.into_packets();
|
||||||
|
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_procedure_call(leaf_id, P::PROCEDURE_ID, hook_id, started_ns);
|
||||||
|
|
||||||
|
for packet in &packets {
|
||||||
|
store.record_outbound_queued(leaf_id, packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox.extend(packets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes a generated leaf-level outbox through endpoint routing.
|
||||||
|
pub fn flush_leaf_outbox(
|
||||||
|
endpoint: &mut Endpoint,
|
||||||
|
leaf_id: u32,
|
||||||
|
outbox: &mut LeafOutbox,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) -> bool {
|
||||||
|
flush_packet_queue_with_interface(endpoint, leaf_id, &mut outbox.packets, interface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes and retains one generated session family.
|
||||||
|
pub fn flush_session_family<L, S>(
|
||||||
|
endpoint: &mut Endpoint,
|
||||||
|
leaf_id: u32,
|
||||||
|
family: &mut SessionFamily<S::State>,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) where
|
||||||
|
S: Session<L>,
|
||||||
|
{
|
||||||
|
for entry in &mut family.entries {
|
||||||
|
flush_packet_queue_with_interface(endpoint, leaf_id, &mut entry.outbox, interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
family
|
||||||
|
.entries
|
||||||
|
.retain(|entry| !entry.closed || !entry.outbox.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes a retry queue through [`Endpoint::add_outbound`].
|
||||||
|
///
|
||||||
|
/// This is the interface-aware version of [`crate::protocol::flush_packet_queue`]. It
|
||||||
|
/// logs route attempts before trying them, then logs either success or the route error
|
||||||
|
/// without dropping the packet on failure.
|
||||||
|
pub fn flush_packet_queue_with_interface(
|
||||||
|
endpoint: &mut Endpoint,
|
||||||
|
leaf_id: u32,
|
||||||
|
outbox: &mut PacketQueue,
|
||||||
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
|
) -> bool {
|
||||||
|
while let Some(packet) = outbox.front().cloned() {
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_route_attempt(leaf_id, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
match endpoint.add_outbound(packet.clone()) {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_route_success(leaf_id, &packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox.pop_front();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(store) = interface.as_mut() {
|
||||||
|
store.record_route_failure(leaf_id, &packet, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path used by generated procedure responses.
|
||||||
|
fn parent_reply_path(endpoint: &Endpoint) -> alloc::vec::Vec<u32> {
|
||||||
|
if endpoint.path.len() > 1 {
|
||||||
|
endpoint.path[..endpoint.path.len() - 1].to_vec()
|
||||||
|
} else {
|
||||||
|
endpoint.path.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::protocol::{Endpoint, HookID, Packet, PacketQueue};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
use crate::interface::SessionView;
|
||||||
|
|
||||||
|
/// Contract implemented by one hook-backed generated session family.
|
||||||
|
///
|
||||||
|
/// A session family maps one outer `procedure_id` to many live hook instances. The
|
||||||
|
/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup;
|
||||||
|
/// the session implementation owns only application behavior.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// impl Session<MyLeafState> for MySession {
|
||||||
|
/// const PROCEDURE_ID: u32 = 7;
|
||||||
|
/// type State = MySessionState;
|
||||||
|
///
|
||||||
|
/// fn reply_path(state: &Self::State) -> &[u32] {
|
||||||
|
/// &state.reply_path
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn init(
|
||||||
|
/// leaf: &mut MyLeafState,
|
||||||
|
/// packet: Packet,
|
||||||
|
/// ctx: &mut SessionInit,
|
||||||
|
/// ) -> SessionInitResult<Self::State> {
|
||||||
|
/// SessionInitResult::Created(MySessionState::from_open(leaf, packet, ctx))
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn update(
|
||||||
|
/// leaf: &mut MyLeafState,
|
||||||
|
/// session: &mut Self::State,
|
||||||
|
/// incoming: &mut PacketQueue,
|
||||||
|
/// ctx: &mut SessionCtx<'_>,
|
||||||
|
/// ) -> SessionStatus {
|
||||||
|
/// while let Some(packet) = incoming.pop_front() {
|
||||||
|
/// session.apply(leaf, packet, ctx);
|
||||||
|
/// }
|
||||||
|
/// SessionStatus::Running
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait Session<L> {
|
||||||
|
/// Outer packet procedure id used by every packet in this session family.
|
||||||
|
const PROCEDURE_ID: u32;
|
||||||
|
|
||||||
|
/// Application state stored for one live hook.
|
||||||
|
type State;
|
||||||
|
|
||||||
|
/// Returns the destination path for responses emitted by this session.
|
||||||
|
///
|
||||||
|
/// `Packet` currently carries only a destination path, so protocols that need to
|
||||||
|
/// reply to a caller should capture a reply path during [`Self::init`]. The
|
||||||
|
/// generated leaf clones this path into [`SessionCtx`] before calling update so
|
||||||
|
/// session code can mutably borrow its state while emitting frames.
|
||||||
|
fn reply_path(session: &Self::State) -> &[u32];
|
||||||
|
|
||||||
|
/// Creates one session state from a packet whose hook has no active session.
|
||||||
|
///
|
||||||
|
/// Returning [`SessionInitResult::RejectedWith`] lets the generated leaf route a
|
||||||
|
/// protocol-level failure response with the same retry guarantees as normal
|
||||||
|
/// output. Returning [`SessionInitResult::Rejected`] silently consumes the packet.
|
||||||
|
fn init(leaf: &mut L, packet: Packet, ctx: &mut SessionInit) -> SessionInitResult<Self::State>;
|
||||||
|
|
||||||
|
/// Advances one active hook session.
|
||||||
|
///
|
||||||
|
/// The generated leaf calls this for every live session on each update tick so
|
||||||
|
/// sessions can poll external workers even when no new packet arrived. Outbound
|
||||||
|
/// packets must be queued through `ctx`; direct endpoint routing would bypass the
|
||||||
|
/// generated retry rules.
|
||||||
|
fn update(
|
||||||
|
leaf: &mut L,
|
||||||
|
session: &mut Self::State,
|
||||||
|
incoming: &mut PacketQueue,
|
||||||
|
ctx: &mut SessionCtx<'_>,
|
||||||
|
) -> SessionStatus;
|
||||||
|
|
||||||
|
#[cfg(feature = "interface_ratatui")]
|
||||||
|
fn render_ratatui(
|
||||||
|
_: &L,
|
||||||
|
_: &Self::State,
|
||||||
|
_: &mut SessionView,
|
||||||
|
_: &mut ratatui::Frame<'_>,
|
||||||
|
_: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context passed to [`Session::init`].
|
||||||
|
///
|
||||||
|
/// This carries routing metadata that the generated leaf already knows before the
|
||||||
|
/// session state exists. Protocols that need source paths should encode them in the
|
||||||
|
/// packet payload; `packet_path` is the destination path that routed the packet here.
|
||||||
|
pub struct SessionInit {
|
||||||
|
hook_id: HookID,
|
||||||
|
packet_path: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionInit {
|
||||||
|
/// Creates initialization metadata for a delivered packet.
|
||||||
|
pub fn new(hook_id: HookID, packet_path: Vec<u32>) -> Self {
|
||||||
|
Self {
|
||||||
|
hook_id,
|
||||||
|
packet_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the hook id that will identify the new session.
|
||||||
|
pub fn hook_id(&self) -> HookID {
|
||||||
|
self.hook_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the destination path from the packet that reached this leaf.
|
||||||
|
pub fn packet_path(&self) -> &[u32] {
|
||||||
|
&self.packet_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of trying to create a session from a packet without an active hook entry.
|
||||||
|
pub enum SessionInitResult<S> {
|
||||||
|
/// A new session was created and should be stored by the generated leaf.
|
||||||
|
Created(S),
|
||||||
|
|
||||||
|
/// The packet was intentionally consumed without creating state or a response.
|
||||||
|
Rejected,
|
||||||
|
|
||||||
|
/// The packet was rejected with a response that the generated leaf must route.
|
||||||
|
RejectedWith(Packet),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session lifecycle status returned from [`Session::update`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SessionStatus {
|
||||||
|
/// The session is active and should receive future update ticks.
|
||||||
|
Running,
|
||||||
|
|
||||||
|
/// The session is winding down but still needs future update ticks.
|
||||||
|
Closing,
|
||||||
|
|
||||||
|
/// The session has finished application work.
|
||||||
|
///
|
||||||
|
/// The generated leaf still retains the entry until every queued packet routes
|
||||||
|
/// successfully, which prevents a failed final frame from losing session cleanup.
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable output context passed to [`Session::update`].
|
||||||
|
///
|
||||||
|
/// The context queues packets only; it never routes them immediately. Centralizing
|
||||||
|
/// routing in generated code is what makes final-frame retries reliable.
|
||||||
|
pub struct SessionCtx<'a> {
|
||||||
|
hook_id: HookID,
|
||||||
|
reply_path: Vec<u32>,
|
||||||
|
procedure_id: u32,
|
||||||
|
outbox: &'a mut PacketQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SessionCtx<'a> {
|
||||||
|
/// Creates a context for one session update call.
|
||||||
|
pub fn new(
|
||||||
|
hook_id: HookID,
|
||||||
|
reply_path: Vec<u32>,
|
||||||
|
procedure_id: u32,
|
||||||
|
outbox: &'a mut PacketQueue,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
hook_id,
|
||||||
|
reply_path,
|
||||||
|
procedure_id,
|
||||||
|
outbox,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the hook id used for packets emitted through this context.
|
||||||
|
pub fn hook_id(&self) -> HookID {
|
||||||
|
self.hook_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the destination path used for packets emitted through this context.
|
||||||
|
pub fn reply_path(&self) -> &[u32] {
|
||||||
|
&self.reply_path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a one-byte-opcode frame without closing the hook.
|
||||||
|
pub fn send(&mut self, opcode: u8, data: &[u8]) {
|
||||||
|
self.send_frame(opcode, data, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a one-byte-opcode frame that closes the hook after successful routing.
|
||||||
|
pub fn send_final(&mut self, opcode: u8, data: &[u8]) {
|
||||||
|
self.send_frame(opcode, data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a protocol-specific error frame without closing the hook.
|
||||||
|
///
|
||||||
|
/// The `code` is used as the frame opcode because the protocol layer does not
|
||||||
|
/// reserve a universal error opcode. Leaves that have a dedicated error opcode can
|
||||||
|
/// pass that value here or call [`Self::send`] directly.
|
||||||
|
pub fn error(&mut self, code: u8, data: &[u8]) {
|
||||||
|
self.send(code, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a protocol-specific error frame that closes the hook after routing.
|
||||||
|
pub fn error_final(&mut self, code: u8, data: &[u8]) {
|
||||||
|
self.send_final(code, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues raw packet data without adding an opcode byte.
|
||||||
|
pub fn send_raw(&mut self, data: &[u8]) {
|
||||||
|
self.send_raw_with_end(data, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues raw packet data and closes the hook after successful routing.
|
||||||
|
pub fn send_raw_final(&mut self, data: &[u8]) {
|
||||||
|
self.send_raw_with_end(data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_frame(&mut self, opcode: u8, data: &[u8], end_hook: bool) {
|
||||||
|
let mut frame = Vec::with_capacity(data.len() + 1);
|
||||||
|
frame.push(opcode);
|
||||||
|
frame.extend_from_slice(data);
|
||||||
|
self.enqueue_data(frame, end_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_raw_with_end(&mut self, data: &[u8], end_hook: bool) {
|
||||||
|
self.enqueue_data(data.to_vec(), end_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_data(&mut self, data: Vec<u8>, end_hook: bool) {
|
||||||
|
self.outbox.push_back(Packet {
|
||||||
|
hook_id: self.hook_id,
|
||||||
|
end_hook,
|
||||||
|
path: self.reply_path.clone(),
|
||||||
|
procedure_id: self.procedure_id,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage entry used by macro-generated session stores.
|
||||||
|
///
|
||||||
|
/// The fields are public so generated code in downstream crates can keep the update
|
||||||
|
/// loop straightforward and static. Handwritten leaves may also use this type, but it
|
||||||
|
/// is intentionally small rather than a full session framework.
|
||||||
|
pub struct SessionEntry<S> {
|
||||||
|
/// Hook id associated with this live session.
|
||||||
|
pub hook_id: HookID,
|
||||||
|
|
||||||
|
/// Application-owned session state.
|
||||||
|
pub state: S,
|
||||||
|
|
||||||
|
/// Packets delivered for this hook but not yet consumed by the session.
|
||||||
|
pub inbox: PacketQueue,
|
||||||
|
|
||||||
|
/// Packets emitted by the session but not yet accepted by endpoint routing.
|
||||||
|
pub outbox: PacketQueue,
|
||||||
|
|
||||||
|
/// Whether application logic has finished and only retry flushing may remain.
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated storage for one session family.
|
||||||
|
///
|
||||||
|
/// The macro only names this field and picks the concrete `Session` type. All update,
|
||||||
|
/// retry, and cleanup behavior lives in normal Rust helpers so the template stays
|
||||||
|
/// small and readable.
|
||||||
|
pub struct SessionFamily<S> {
|
||||||
|
/// Active hook-backed sessions for this family.
|
||||||
|
pub entries: Vec<SessionEntry<S>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> SessionFamily<S> {
|
||||||
|
/// Creates an empty session family.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts packets retained by this family for retry or future session work.
|
||||||
|
pub fn pending_packet_count(&self) -> usize {
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
|
for entry in &self.entries {
|
||||||
|
count += entry.inbox.len() + entry.outbox.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Default for SessionFamily<S> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> SessionEntry<S> {
|
||||||
|
/// Creates one active session entry for `hook_id`.
|
||||||
|
pub fn new(hook_id: HookID, state: S) -> Self {
|
||||||
|
Self {
|
||||||
|
hook_id,
|
||||||
|
state,
|
||||||
|
inbox: PacketQueue::new(),
|
||||||
|
outbox: PacketQueue::new(),
|
||||||
|
closed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes a retry queue through [`Endpoint::add_outbound`].
|
||||||
|
///
|
||||||
|
/// The packet at the front is cloned for each attempt and removed only after routing
|
||||||
|
/// succeeds. This preserves final frames when a route is temporarily unavailable.
|
||||||
|
/// The return value is true when the queue was fully drained.
|
||||||
|
pub fn flush_packet_queue(endpoint: &mut Endpoint, outbox: &mut PacketQueue) -> bool {
|
||||||
|
while let Some(packet) = outbox.front().cloned() {
|
||||||
|
if endpoint.add_outbound(packet).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use super::tree::{BlockChunk, ChildKind, ChildSummary};
|
||||||
|
|
||||||
|
/// Encodes one `u32` request or response payload.
|
||||||
|
pub(super) fn encode_u32(value: u32) -> Vec<u8> {
|
||||||
|
value.to_le_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one exact `u32` payload.
|
||||||
|
pub(super) fn decode_u32(data: &[u8]) -> Option<u32> {
|
||||||
|
if data.len() == 4 {
|
||||||
|
Some(read_u32(data, 0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one streamed child hash entry.
|
||||||
|
pub(super) fn encode_child_summary(summary: ChildSummary) -> Vec<u8> {
|
||||||
|
let mut data = Vec::with_capacity(12);
|
||||||
|
data.extend_from_slice(&summary.id.to_le_bytes());
|
||||||
|
data.extend_from_slice(&summary.kind.discriminant().to_le_bytes());
|
||||||
|
data.extend_from_slice(&summary.hash.to_le_bytes());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one streamed child hash entry.
|
||||||
|
pub(super) fn decode_child_summary(data: &[u8]) -> Option<ChildSummary> {
|
||||||
|
if data.len() != 12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ChildSummary {
|
||||||
|
id: read_u32(data, 0),
|
||||||
|
kind: ChildKind::from_discriminant(read_u32(data, 4))?,
|
||||||
|
hash: read_u32(data, 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one streamed block chunk.
|
||||||
|
pub(super) fn encode_block_chunk(chunk: &BlockChunk) -> Vec<u8> {
|
||||||
|
let mut data = Vec::with_capacity(16 + chunk.data.len());
|
||||||
|
data.extend_from_slice(&chunk.block_id.to_le_bytes());
|
||||||
|
data.extend_from_slice(&chunk.index.to_le_bytes());
|
||||||
|
data.extend_from_slice(&chunk.total.to_le_bytes());
|
||||||
|
data.extend_from_slice(&(chunk.data.len() as u32).to_le_bytes());
|
||||||
|
data.extend_from_slice(&chunk.data);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one streamed block chunk.
|
||||||
|
pub(super) fn decode_block_chunk(data: &[u8]) -> Option<BlockChunk> {
|
||||||
|
if data.len() < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = read_u32(data, 12) as usize;
|
||||||
|
if data.len() != 16 + len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(BlockChunk {
|
||||||
|
block_id: read_u32(data, 0),
|
||||||
|
index: read_u32(data, 4),
|
||||||
|
total: read_u32(data, 8),
|
||||||
|
data: data[16..].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a little-endian `u32` at a known-valid offset.
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> u32 {
|
||||||
|
u32::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2],
|
||||||
|
data[offset + 3],
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Shared ids for the Merkle sync protocol test.
|
||||||
|
//!
|
||||||
|
//! Keeping ids in one file makes the manually managed leaf state easier to audit
|
||||||
|
//! and mirrors the table a future leaf-state macro would generate from annotated
|
||||||
|
//! RPC definitions.
|
||||||
|
|
||||||
|
pub(super) const ENDPOINT_CALLER: u32 = 0;
|
||||||
|
pub(super) const ENDPOINT_RESPONDENT: u32 = 1;
|
||||||
|
|
||||||
|
pub(super) const LEAF_MERKLE_CALLER: u32 = 300;
|
||||||
|
pub(super) const LEAF_MERKLE_RESPONDENT: u32 = 301;
|
||||||
|
pub(super) const LEAF_MOCK_CONNECTION: u32 = 302;
|
||||||
|
|
||||||
|
pub(super) const PROC_GET_ROOT_HASH: u32 = 10;
|
||||||
|
pub(super) const PROC_GET_CHILD_HASHES: u32 = 11;
|
||||||
|
pub(super) const PROC_GET_BLOCK_STREAM: u32 = 12;
|
||||||
|
pub(super) const PROC_ROOT_HASH: u32 = 20;
|
||||||
|
pub(super) const PROC_CHILD_HASH_ENTRY: u32 = 21;
|
||||||
|
pub(super) const PROC_BLOCK_CHUNK: u32 = 22;
|
||||||
|
|
||||||
|
pub(super) const ROOT_NODE: u32 = 0;
|
||||||
|
pub(super) const BRANCH_LEFT: u32 = 1;
|
||||||
|
pub(super) const BRANCH_RIGHT: u32 = 2;
|
||||||
|
pub(super) const BLOCK_ALPHA: u32 = 10;
|
||||||
|
pub(super) const BLOCK_BRAVO: u32 = 11;
|
||||||
|
pub(super) const BLOCK_CHARLIE: u32 = 20;
|
||||||
|
pub(super) const BLOCK_DELTA: u32 = 21;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use alloc::{boxed::Box, rc::Rc, vec};
|
||||||
|
use core::cell::RefCell;
|
||||||
|
|
||||||
|
use crate::protocol::Endpoint;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
constants::{ENDPOINT_CALLER, ENDPOINT_RESPONDENT},
|
||||||
|
leaves::{MerkleCallerLeaf, MerkleRespondentLeaf, MockConnectionLeaf},
|
||||||
|
state::{CallerReport, RespondentReport},
|
||||||
|
tree::{MerkleStore, local_fixture, remote_fixture},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Complete two-endpoint Merkle sync test harness.
|
||||||
|
///
|
||||||
|
/// Endpoint A owns the caller leaf and one mock connection leaf. Endpoint B owns the
|
||||||
|
/// respondent leaf and the opposite mock connection leaf. Reports are shared out of
|
||||||
|
/// the boxed leaf objects so tests can assert state without downcasting trait
|
||||||
|
/// objects.
|
||||||
|
pub(super) struct MerkleHarness {
|
||||||
|
pub(super) endpoint_a: Endpoint,
|
||||||
|
pub(super) endpoint_b: Endpoint,
|
||||||
|
pub(super) caller_report: Rc<RefCell<CallerReport>>,
|
||||||
|
pub(super) respondent_report: Rc<RefCell<RespondentReport>>,
|
||||||
|
pub(super) remote_root_hash: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleHarness {
|
||||||
|
/// Creates the divergent fixture used by the main sync test.
|
||||||
|
pub(super) fn divergent() -> Self {
|
||||||
|
Self::with_stores(local_fixture(), remote_fixture())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a custom caller/respondent fixture.
|
||||||
|
pub(super) fn with_stores(local: MerkleStore, remote: MerkleStore) -> Self {
|
||||||
|
let remote_root_hash = remote.root_hash();
|
||||||
|
let caller_report = Rc::new(RefCell::new(CallerReport::default()));
|
||||||
|
let respondent_report = Rc::new(RefCell::new(RespondentReport::default()));
|
||||||
|
let (tx_a, rx_a) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_b, rx_b) = crossbeam_channel::unbounded();
|
||||||
|
|
||||||
|
let mut endpoint_a = Endpoint::new(
|
||||||
|
ENDPOINT_CALLER,
|
||||||
|
vec![
|
||||||
|
Box::new(MerkleCallerLeaf::new(local, caller_report.clone())),
|
||||||
|
Box::new(MockConnectionLeaf::new(
|
||||||
|
tx_b,
|
||||||
|
rx_a,
|
||||||
|
ENDPOINT_RESPONDENT,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_a.path = vec![ENDPOINT_CALLER];
|
||||||
|
|
||||||
|
let mut endpoint_b = Endpoint::new(
|
||||||
|
ENDPOINT_RESPONDENT,
|
||||||
|
vec![
|
||||||
|
Box::new(MerkleRespondentLeaf::new(remote, respondent_report.clone())),
|
||||||
|
Box::new(MockConnectionLeaf::new(tx_a, rx_b, ENDPOINT_CALLER, true)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_b.path = vec![ENDPOINT_CALLER, ENDPOINT_RESPONDENT];
|
||||||
|
|
||||||
|
// Register routes before the first caller update so initial packet delivery
|
||||||
|
// does not depend on leaf ordering.
|
||||||
|
endpoint_a.connections.insert((ENDPOINT_RESPONDENT, false));
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_CALLER, true));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
endpoint_a,
|
||||||
|
endpoint_b,
|
||||||
|
caller_report,
|
||||||
|
respondent_report,
|
||||||
|
remote_root_hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives one deterministic protocol loop.
|
||||||
|
pub(super) fn tick(&mut self) {
|
||||||
|
self.endpoint_a.update();
|
||||||
|
self.endpoint_b.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs until the caller reports completion.
|
||||||
|
pub(super) fn run_until_done(&mut self, max_ticks: usize) -> usize {
|
||||||
|
for tick in 1..=max_ticks {
|
||||||
|
self.tick();
|
||||||
|
|
||||||
|
if self.caller_report.borrow().done {
|
||||||
|
return tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!("Merkle sync did not finish within {max_ticks} ticks");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs until the respondent has sent at least `target_frames` frames.
|
||||||
|
pub(super) fn run_until_respondent_frames(
|
||||||
|
&mut self,
|
||||||
|
target_frames: usize,
|
||||||
|
max_ticks: usize,
|
||||||
|
) -> usize {
|
||||||
|
for tick in 1..=max_ticks {
|
||||||
|
self.tick();
|
||||||
|
|
||||||
|
if self.respondent_report.borrow().frames_sent >= target_frames {
|
||||||
|
return tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!("respondent did not send {target_frames} frames within {max_ticks} ticks");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the requested four-leaf topology.
|
||||||
|
pub(super) fn assert_four_leaf_topology(&self) {
|
||||||
|
assert_eq!(self.endpoint_a.leaves.len(), 2);
|
||||||
|
assert_eq!(self.endpoint_b.leaves.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
use alloc::{collections::VecDeque, rc::Rc, vec, vec::Vec};
|
||||||
|
use core::cell::RefCell;
|
||||||
|
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
|
||||||
|
use crate::protocol::{Endpoint, Leaf, Packet};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
use crate::protocol::LeafMeta;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
codec::{decode_block_chunk, decode_child_summary, decode_u32},
|
||||||
|
constants::{
|
||||||
|
ENDPOINT_CALLER, ENDPOINT_RESPONDENT, LEAF_MERKLE_CALLER, LEAF_MERKLE_RESPONDENT,
|
||||||
|
LEAF_MOCK_CONNECTION, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY, PROC_GET_BLOCK_STREAM,
|
||||||
|
PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH, ROOT_NODE,
|
||||||
|
},
|
||||||
|
rpc::{
|
||||||
|
block_chunk_frame, block_stream_request, child_hash_frame, child_hashes_request,
|
||||||
|
root_hash_frame, root_hash_request,
|
||||||
|
},
|
||||||
|
state::{CallerPhase, CallerReport, RespondentReport, ResponseStream},
|
||||||
|
tree::{BlockChunk, ChildKind, MerkleStore},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Leaf that simulates a serialized transport connection with crossbeam channels.
|
||||||
|
///
|
||||||
|
/// This is intentionally tiny and reusable. Both endpoints in the Merkle test have
|
||||||
|
/// exactly one of these leaves, giving the requested four-leaf topology: caller,
|
||||||
|
/// respondent, and two mock connections.
|
||||||
|
pub(super) struct MockConnectionLeaf {
|
||||||
|
pub(super) tx: Sender<Vec<u8>>,
|
||||||
|
pub(super) rx: Receiver<Vec<u8>>,
|
||||||
|
pub(super) remote_id: u32,
|
||||||
|
pub(super) is_authority: bool,
|
||||||
|
pub(super) started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller leaf that drives the Merkle synchronization algorithm.
|
||||||
|
pub(super) struct MerkleCallerLeaf {
|
||||||
|
local: MerkleStore,
|
||||||
|
phase: CallerPhase,
|
||||||
|
pending_nodes: VecDeque<u32>,
|
||||||
|
pending_blocks: VecDeque<u32>,
|
||||||
|
report: Rc<RefCell<CallerReport>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respondent leaf that serves Merkle hash and block streams.
|
||||||
|
pub(super) struct MerkleRespondentLeaf {
|
||||||
|
remote: MerkleStore,
|
||||||
|
active_stream: Option<ResponseStream>,
|
||||||
|
report: Rc<RefCell<RespondentReport>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockConnectionLeaf {
|
||||||
|
/// Creates one side of a mock connection.
|
||||||
|
pub(super) fn new(
|
||||||
|
tx: Sender<Vec<u8>>,
|
||||||
|
rx: Receiver<Vec<u8>>,
|
||||||
|
remote_id: u32,
|
||||||
|
is_authority: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
rx,
|
||||||
|
remote_id,
|
||||||
|
is_authority,
|
||||||
|
started: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleCallerLeaf {
|
||||||
|
/// Creates a caller with a local store and externally visible report.
|
||||||
|
pub(super) fn new(local: MerkleStore, report: Rc<RefCell<CallerReport>>) -> Self {
|
||||||
|
Self {
|
||||||
|
local,
|
||||||
|
phase: CallerPhase::NeedRoot,
|
||||||
|
pending_nodes: VecDeque::new(),
|
||||||
|
pending_blocks: VecDeque::new(),
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleRespondentLeaf {
|
||||||
|
/// Creates a respondent backed by the authoritative remote store.
|
||||||
|
pub(super) fn new(remote: MerkleStore, report: Rc<RefCell<RespondentReport>>) -> Self {
|
||||||
|
Self {
|
||||||
|
remote,
|
||||||
|
active_stream: None,
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for MockConnectionLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_MOCK_CONNECTION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Merke Connection Leaf",
|
||||||
|
identifier: "dev.unshell.test.merkle.connection",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if !self.started {
|
||||||
|
endpoint
|
||||||
|
.connections
|
||||||
|
.insert((self.remote_id, self.is_authority));
|
||||||
|
self.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !self.rx.is_empty() {
|
||||||
|
let data = self.rx.recv().unwrap();
|
||||||
|
|
||||||
|
// Mock transports move untrusted bytes. Malformed frames are dropped so
|
||||||
|
// the sync state machine is tested only after packet parsing succeeds.
|
||||||
|
if let Ok(packet) = Packet::deserialize(&data) {
|
||||||
|
let _ = endpoint.add_inbound_from(self.remote_id, packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.take_outbound_clear(self.remote_id, |packet| {
|
||||||
|
let data = packet.serialize().unwrap();
|
||||||
|
let _ = self.tx.send(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for MerkleCallerLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_MERKLE_CALLER
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Merke Caller Leaf",
|
||||||
|
identifier: "dev.unshell.test.merkle.caller",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
self.receive_responses(endpoint);
|
||||||
|
self.dispatch_next_request(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for MerkleRespondentLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_MERKLE_RESPONDENT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Merke Respondent Leaf",
|
||||||
|
identifier: "dev.unshell.test.merkle.respondent",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
self.open_stream_from_request(endpoint);
|
||||||
|
self.send_one_response_frame(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleCallerLeaf {
|
||||||
|
/// Consumes all response packets currently delivered to endpoint A.
|
||||||
|
fn receive_responses(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
endpoint.take_inbound_clear(ENDPOINT_CALLER, |packet| {
|
||||||
|
self.report
|
||||||
|
.borrow_mut()
|
||||||
|
.received_procedures
|
||||||
|
.push(packet.procedure_id);
|
||||||
|
self.handle_response_packet(packet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles one response packet according to the current caller phase.
|
||||||
|
fn handle_response_packet(&mut self, packet: &Packet) {
|
||||||
|
match &mut self.phase {
|
||||||
|
CallerPhase::AwaitRoot { hook_id } => {
|
||||||
|
assert_eq!(packet.hook_id, *hook_id);
|
||||||
|
assert_eq!(packet.procedure_id, PROC_ROOT_HASH);
|
||||||
|
let remote_root = decode_u32(&packet.data).expect("root hash payload");
|
||||||
|
|
||||||
|
if packet.end_hook {
|
||||||
|
self.finish_root_response(remote_root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallerPhase::AwaitChildren {
|
||||||
|
hook_id,
|
||||||
|
node_id: _,
|
||||||
|
entries,
|
||||||
|
} => {
|
||||||
|
assert_eq!(packet.hook_id, *hook_id);
|
||||||
|
assert_eq!(packet.procedure_id, PROC_CHILD_HASH_ENTRY);
|
||||||
|
entries.push(decode_child_summary(&packet.data).expect("child summary payload"));
|
||||||
|
|
||||||
|
if packet.end_hook {
|
||||||
|
self.finish_child_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallerPhase::AwaitBlock {
|
||||||
|
hook_id,
|
||||||
|
block_id: _,
|
||||||
|
chunks,
|
||||||
|
} => {
|
||||||
|
assert_eq!(packet.hook_id, *hook_id);
|
||||||
|
assert_eq!(packet.procedure_id, PROC_BLOCK_CHUNK);
|
||||||
|
chunks.push(decode_block_chunk(&packet.data).expect("block chunk payload"));
|
||||||
|
|
||||||
|
if packet.end_hook {
|
||||||
|
self.finish_block_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallerPhase::NeedRoot | CallerPhase::Ready | CallerPhase::Done => {
|
||||||
|
panic!("unexpected Merkle response in phase {:?}", self.phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the completed root response and decides whether tree walking is needed.
|
||||||
|
fn finish_root_response(&mut self, remote_root: u32) {
|
||||||
|
if self.local.root_hash() == remote_root {
|
||||||
|
self.mark_done();
|
||||||
|
} else {
|
||||||
|
self.pending_nodes.push_back(ROOT_NODE);
|
||||||
|
self.phase = CallerPhase::Ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a completed child-hash stream.
|
||||||
|
fn finish_child_response(&mut self) {
|
||||||
|
let CallerPhase::AwaitChildren {
|
||||||
|
hook_id: _,
|
||||||
|
node_id: _,
|
||||||
|
entries,
|
||||||
|
} = core::mem::replace(&mut self.phase, CallerPhase::Ready)
|
||||||
|
else {
|
||||||
|
unreachable!();
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if self.local.hash_for(entry.kind, entry.id) == entry.hash {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match entry.kind {
|
||||||
|
ChildKind::Branch => self.pending_nodes.push_back(entry.id),
|
||||||
|
ChildKind::Block => self.pending_blocks.push_back(entry.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a completed block stream to the local store.
|
||||||
|
fn finish_block_response(&mut self) {
|
||||||
|
let CallerPhase::AwaitBlock {
|
||||||
|
hook_id: _,
|
||||||
|
block_id,
|
||||||
|
mut chunks,
|
||||||
|
} = core::mem::replace(&mut self.phase, CallerPhase::Ready)
|
||||||
|
else {
|
||||||
|
unreachable!();
|
||||||
|
};
|
||||||
|
|
||||||
|
chunks.sort_by_key(|chunk| chunk.index);
|
||||||
|
assert_eq!(
|
||||||
|
chunks.len(),
|
||||||
|
chunks.first().map(|chunk| chunk.total).unwrap_or(0) as usize
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_chunks: Vec<Vec<u8>> = chunks.into_iter().map(|chunk| chunk.data).collect();
|
||||||
|
self.local.replace_block(block_id, new_chunks.clone());
|
||||||
|
|
||||||
|
let mut report = self.report.borrow_mut();
|
||||||
|
report.synchronized_blocks.push(block_id);
|
||||||
|
report.applied_block_chunks.push((block_id, new_chunks));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the next request if the caller is not waiting on a response stream.
|
||||||
|
fn dispatch_next_request(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
match self.phase {
|
||||||
|
CallerPhase::NeedRoot => {
|
||||||
|
let hook_id = self.send_request(endpoint, PROC_GET_ROOT_HASH, Vec::new());
|
||||||
|
endpoint.add_outbound(root_hash_request(hook_id)).unwrap();
|
||||||
|
self.phase = CallerPhase::AwaitRoot { hook_id };
|
||||||
|
}
|
||||||
|
CallerPhase::Ready => {
|
||||||
|
if let Some(node_id) = self.pending_nodes.pop_front() {
|
||||||
|
let hook_id = self.send_request(endpoint, PROC_GET_CHILD_HASHES, Vec::new());
|
||||||
|
endpoint
|
||||||
|
.add_outbound(child_hashes_request(hook_id, node_id))
|
||||||
|
.unwrap();
|
||||||
|
self.phase = CallerPhase::AwaitChildren {
|
||||||
|
hook_id,
|
||||||
|
node_id,
|
||||||
|
entries: Vec::new(),
|
||||||
|
};
|
||||||
|
} else if let Some(block_id) = self.pending_blocks.pop_front() {
|
||||||
|
let hook_id = self.send_request(endpoint, PROC_GET_BLOCK_STREAM, Vec::new());
|
||||||
|
endpoint
|
||||||
|
.add_outbound(block_stream_request(hook_id, block_id))
|
||||||
|
.unwrap();
|
||||||
|
self.phase = CallerPhase::AwaitBlock {
|
||||||
|
hook_id,
|
||||||
|
block_id,
|
||||||
|
chunks: Vec::new(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
self.mark_done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CallerPhase::AwaitRoot { .. }
|
||||||
|
| CallerPhase::AwaitChildren { .. }
|
||||||
|
| CallerPhase::AwaitBlock { .. }
|
||||||
|
| CallerPhase::Done => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves a hook id and records the logical RPC request.
|
||||||
|
fn send_request(&mut self, endpoint: &mut Endpoint, procedure_id: u32, _data: Vec<u8>) -> u16 {
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
self.report
|
||||||
|
.borrow_mut()
|
||||||
|
.requested_procedures
|
||||||
|
.push(procedure_id);
|
||||||
|
hook_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the synchronization complete and records the final local root.
|
||||||
|
fn mark_done(&mut self) {
|
||||||
|
self.phase = CallerPhase::Done;
|
||||||
|
let mut report = self.report.borrow_mut();
|
||||||
|
report.done = true;
|
||||||
|
report.final_root_hash = Some(self.local.root_hash());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleRespondentLeaf {
|
||||||
|
/// Opens one response stream from the first pending local request.
|
||||||
|
fn open_stream_from_request(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if self.active_stream.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request = None;
|
||||||
|
endpoint.take_inbound_clear(ENDPOINT_RESPONDENT, |packet| {
|
||||||
|
if request.is_none() {
|
||||||
|
request = Some((packet.hook_id, packet.procedure_id, packet.data.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some((hook_id, procedure_id, data)) = request else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let frames = self.frames_for_request(procedure_id, &data);
|
||||||
|
|
||||||
|
self.report.borrow_mut().requests_seen.push(procedure_id);
|
||||||
|
if !frames.is_empty() {
|
||||||
|
self.report.borrow_mut().streams_started += 1;
|
||||||
|
self.active_stream = Some(ResponseStream::new(hook_id, frames));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds response frames for one request procedure.
|
||||||
|
fn frames_for_request(&self, procedure_id: u32, data: &[u8]) -> Vec<super::rpc::OutgoingFrame> {
|
||||||
|
match procedure_id {
|
||||||
|
PROC_GET_ROOT_HASH => vec![root_hash_frame(self.remote.root_hash())],
|
||||||
|
PROC_GET_CHILD_HASHES => {
|
||||||
|
let node_id = decode_u32(data).expect("child hash request node id");
|
||||||
|
self.remote
|
||||||
|
.child_summaries(node_id)
|
||||||
|
.into_iter()
|
||||||
|
.map(child_hash_frame)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
PROC_GET_BLOCK_STREAM => {
|
||||||
|
let block_id = decode_u32(data).expect("block stream request block id");
|
||||||
|
let chunks = self.remote.block_chunks(block_id);
|
||||||
|
let total = chunks.len() as u32;
|
||||||
|
chunks
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, data)| {
|
||||||
|
block_chunk_frame(BlockChunk {
|
||||||
|
block_id,
|
||||||
|
index: index as u32,
|
||||||
|
total,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends at most one response frame per update loop.
|
||||||
|
fn send_one_response_frame(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
let Some(stream) = self.active_stream.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if stream.is_empty() {
|
||||||
|
self.active_stream = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packet = stream.next_packet().expect("active stream frame");
|
||||||
|
if endpoint.add_outbound(packet).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.report.borrow_mut().frames_sent += 1;
|
||||||
|
stream.advance();
|
||||||
|
|
||||||
|
if stream.is_complete() {
|
||||||
|
self.report.borrow_mut().streams_completed += 1;
|
||||||
|
self.active_stream = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
mod codec;
|
||||||
|
mod constants;
|
||||||
|
mod harness;
|
||||||
|
mod leaves;
|
||||||
|
mod rpc;
|
||||||
|
mod state;
|
||||||
|
mod tests;
|
||||||
|
mod tree;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
|
||||||
|
use crate::protocol::Packet;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
codec::{encode_block_chunk, encode_child_summary, encode_u32},
|
||||||
|
constants::{
|
||||||
|
ENDPOINT_CALLER, ENDPOINT_RESPONDENT, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY,
|
||||||
|
PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH,
|
||||||
|
},
|
||||||
|
tree::{BlockChunk, ChildSummary},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One outbound response frame before it is wrapped in endpoint routing fields.
|
||||||
|
///
|
||||||
|
/// A response stream owns a list of these frames and asks each frame to become a
|
||||||
|
/// packet only when the loop is ready to send it. That keeps retry behavior simple:
|
||||||
|
/// a failed send does not consume the frame.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) struct OutgoingFrame {
|
||||||
|
procedure_id: u32,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutgoingFrame {
|
||||||
|
/// Wraps the frame in an upward packet for `hook_id`.
|
||||||
|
pub(super) fn to_packet(&self, hook_id: u16, end_hook: bool) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
path: vec![ENDPOINT_CALLER],
|
||||||
|
procedure_id: self.procedure_id,
|
||||||
|
data: self.data.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the initial root-hash request.
|
||||||
|
pub(super) fn root_hash_request(hook_id: u16) -> Packet {
|
||||||
|
request_packet(PROC_GET_ROOT_HASH, hook_id, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a request for one branch node's child hashes.
|
||||||
|
pub(super) fn child_hashes_request(hook_id: u16, node_id: u32) -> Packet {
|
||||||
|
request_packet(PROC_GET_CHILD_HASHES, hook_id, encode_u32(node_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a request for one mismatched block's data stream.
|
||||||
|
pub(super) fn block_stream_request(hook_id: u16, block_id: u32) -> Packet {
|
||||||
|
request_packet(PROC_GET_BLOCK_STREAM, hook_id, encode_u32(block_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a single root-hash response frame.
|
||||||
|
pub(super) fn root_hash_frame(root_hash: u32) -> OutgoingFrame {
|
||||||
|
OutgoingFrame {
|
||||||
|
procedure_id: PROC_ROOT_HASH,
|
||||||
|
data: encode_u32(root_hash),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one streamed child hash entry response frame.
|
||||||
|
pub(super) fn child_hash_frame(summary: ChildSummary) -> OutgoingFrame {
|
||||||
|
OutgoingFrame {
|
||||||
|
procedure_id: PROC_CHILD_HASH_ENTRY,
|
||||||
|
data: encode_child_summary(summary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one streamed block chunk response frame.
|
||||||
|
pub(super) fn block_chunk_frame(chunk: BlockChunk) -> OutgoingFrame {
|
||||||
|
OutgoingFrame {
|
||||||
|
procedure_id: PROC_BLOCK_CHUNK,
|
||||||
|
data: encode_block_chunk(&chunk),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a downward request packet.
|
||||||
|
fn request_packet(procedure_id: u32, hook_id: u16, data: Vec<u8>) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook: false,
|
||||||
|
path: vec![ENDPOINT_CALLER, ENDPOINT_RESPONDENT],
|
||||||
|
procedure_id,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use crate::protocol::Packet;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
rpc::OutgoingFrame,
|
||||||
|
tree::{BlockChunk, ChildSummary},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Caller-side synchronization phase.
|
||||||
|
///
|
||||||
|
/// This is the manual state machine a future macro should be able to derive from
|
||||||
|
/// RPC declarations. Each awaiting state owns the partial stream it is collecting,
|
||||||
|
/// making it clear which packets are legal at each step.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) enum CallerPhase {
|
||||||
|
NeedRoot,
|
||||||
|
AwaitRoot {
|
||||||
|
hook_id: u16,
|
||||||
|
},
|
||||||
|
Ready,
|
||||||
|
AwaitChildren {
|
||||||
|
hook_id: u16,
|
||||||
|
node_id: u32,
|
||||||
|
entries: Vec<ChildSummary>,
|
||||||
|
},
|
||||||
|
AwaitBlock {
|
||||||
|
hook_id: u16,
|
||||||
|
block_id: u32,
|
||||||
|
chunks: Vec<BlockChunk>,
|
||||||
|
},
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-visible caller observations.
|
||||||
|
///
|
||||||
|
/// The leaf itself lives behind `Box<dyn Leaf>`, so the harness keeps a shared
|
||||||
|
/// report handle for assertions without needing downcasts.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(super) struct CallerReport {
|
||||||
|
pub(super) done: bool,
|
||||||
|
pub(super) requested_procedures: Vec<u32>,
|
||||||
|
pub(super) received_procedures: Vec<u32>,
|
||||||
|
pub(super) synchronized_blocks: Vec<u32>,
|
||||||
|
pub(super) applied_block_chunks: Vec<(u32, Vec<Vec<u8>>)>,
|
||||||
|
pub(super) final_root_hash: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-visible respondent observations.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(super) struct RespondentReport {
|
||||||
|
pub(super) requests_seen: Vec<u32>,
|
||||||
|
pub(super) streams_started: usize,
|
||||||
|
pub(super) streams_completed: usize,
|
||||||
|
pub(super) frames_sent: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respondent-owned response stream.
|
||||||
|
///
|
||||||
|
/// It stores encoded frames and exposes packet construction one frame at a time.
|
||||||
|
/// Since `next_packet` does not advance, a failed route can be retried by calling it
|
||||||
|
/// again on the next loop.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) struct ResponseStream {
|
||||||
|
hook_id: u16,
|
||||||
|
frames: Vec<OutgoingFrame>,
|
||||||
|
next_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseStream {
|
||||||
|
/// Creates a response stream for one request hook.
|
||||||
|
pub(super) fn new(hook_id: u16, frames: Vec<OutgoingFrame>) -> Self {
|
||||||
|
Self {
|
||||||
|
hook_id,
|
||||||
|
frames,
|
||||||
|
next_index: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the next packet without advancing the stream.
|
||||||
|
pub(super) fn next_packet(&self) -> Option<Packet> {
|
||||||
|
let frame = self.frames.get(self.next_index)?;
|
||||||
|
Some(frame.to_packet(self.hook_id, self.next_index + 1 == self.frames.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the current frame as successfully sent.
|
||||||
|
pub(super) fn advance(&mut self) {
|
||||||
|
self.next_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true once every frame has been sent.
|
||||||
|
pub(super) fn is_complete(&self) -> bool {
|
||||||
|
self.next_index >= self.frames.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when the request generated no frames.
|
||||||
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
|
self.frames.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use super::{
|
||||||
|
constants::{
|
||||||
|
BLOCK_BRAVO, BLOCK_CHARLIE, PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES,
|
||||||
|
PROC_GET_ROOT_HASH,
|
||||||
|
},
|
||||||
|
harness::MerkleHarness,
|
||||||
|
tree::remote_fixture,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merkle_sync_walks_hash_tree_and_streams_changed_blocks() {
|
||||||
|
let mut harness = MerkleHarness::divergent();
|
||||||
|
harness.assert_four_leaf_topology();
|
||||||
|
|
||||||
|
let ticks = harness.run_until_done(100);
|
||||||
|
assert!(
|
||||||
|
ticks > 20,
|
||||||
|
"sync should require many request/stream iterations"
|
||||||
|
);
|
||||||
|
|
||||||
|
let caller = harness.caller_report.borrow();
|
||||||
|
assert_eq!(caller.final_root_hash, Some(harness.remote_root_hash));
|
||||||
|
assert_eq!(caller.synchronized_blocks, [BLOCK_BRAVO, BLOCK_CHARLIE]);
|
||||||
|
assert_eq!(
|
||||||
|
caller.requested_procedures,
|
||||||
|
[
|
||||||
|
PROC_GET_ROOT_HASH,
|
||||||
|
PROC_GET_CHILD_HASHES,
|
||||||
|
PROC_GET_CHILD_HASHES,
|
||||||
|
PROC_GET_CHILD_HASHES,
|
||||||
|
PROC_GET_BLOCK_STREAM,
|
||||||
|
PROC_GET_BLOCK_STREAM,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let respondent = harness.respondent_report.borrow();
|
||||||
|
assert_eq!(respondent.requests_seen, caller.requested_procedures);
|
||||||
|
assert_eq!(respondent.streams_started, 6);
|
||||||
|
assert_eq!(respondent.streams_completed, 6);
|
||||||
|
assert_eq!(respondent.frames_sent, 12);
|
||||||
|
assert_eq!(harness.endpoint_b.hook_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_tree_stops_after_root_hash() {
|
||||||
|
let remote = remote_fixture();
|
||||||
|
let mut harness = MerkleHarness::with_stores(remote.clone(), remote);
|
||||||
|
|
||||||
|
harness.run_until_done(20);
|
||||||
|
|
||||||
|
let caller = harness.caller_report.borrow();
|
||||||
|
assert_eq!(caller.final_root_hash, Some(harness.remote_root_hash));
|
||||||
|
assert_eq!(caller.requested_procedures, [PROC_GET_ROOT_HASH]);
|
||||||
|
assert!(caller.synchronized_blocks.is_empty());
|
||||||
|
|
||||||
|
let respondent = harness.respondent_report.borrow();
|
||||||
|
assert_eq!(respondent.frames_sent, 1);
|
||||||
|
assert_eq!(respondent.streams_started, 1);
|
||||||
|
assert_eq!(respondent.streams_completed, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_stream_hook_persists_until_final_frame() {
|
||||||
|
let mut harness = MerkleHarness::divergent();
|
||||||
|
|
||||||
|
harness.run_until_respondent_frames(8, 100);
|
||||||
|
assert_eq!(
|
||||||
|
harness.endpoint_b.hook_count(),
|
||||||
|
1,
|
||||||
|
"first block stream should keep its hook after a non-final chunk"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.run_until_done(100);
|
||||||
|
assert!(
|
||||||
|
harness.endpoint_b.hook_count() == 0,
|
||||||
|
"final block stream packet should clean respondent hook state"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
use alloc::{collections::BTreeMap, vec, vec::Vec};
|
||||||
|
|
||||||
|
use super::constants::{
|
||||||
|
BLOCK_ALPHA, BLOCK_BRAVO, BLOCK_CHARLIE, BLOCK_DELTA, BRANCH_LEFT, BRANCH_RIGHT, ROOT_NODE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Type of child referenced by a Merkle node summary.
|
||||||
|
///
|
||||||
|
/// The sync caller uses this to decide whether a mismatched child should recurse
|
||||||
|
/// with `GET_CHILD_HASHES` or transfer data with `GET_BLOCK_STREAM`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum ChildKind {
|
||||||
|
Branch,
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One child entry in a streamed Merkle summary response.
|
||||||
|
///
|
||||||
|
/// A respondent streams these one per loop. The caller compares each `hash` with
|
||||||
|
/// its local store and queues either another node walk or a block transfer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) struct ChildSummary {
|
||||||
|
pub(super) id: u32,
|
||||||
|
pub(super) kind: ChildKind,
|
||||||
|
pub(super) hash: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One chunk in a streamed block response.
|
||||||
|
///
|
||||||
|
/// Chunks carry their total so the caller can replace the local block only after
|
||||||
|
/// the final stream packet arrives. This keeps partially received data out of the
|
||||||
|
/// Merkle hash until the hook completes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) struct BlockChunk {
|
||||||
|
pub(super) block_id: u32,
|
||||||
|
pub(super) index: u32,
|
||||||
|
pub(super) total: u32,
|
||||||
|
pub(super) data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static edge in the test Merkle tree.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct TreeChild {
|
||||||
|
id: u32,
|
||||||
|
kind: ChildKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory Merkle store used by the caller and respondent leaves.
|
||||||
|
///
|
||||||
|
/// This is deliberately small but extensible: adding wider trees, extra branches,
|
||||||
|
/// or different block chunking only changes this store, not the endpoint routing
|
||||||
|
/// harness. The hash is not cryptographic; it is deterministic test content used to
|
||||||
|
/// exercise the protocol state machine.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct MerkleStore {
|
||||||
|
root_id: u32,
|
||||||
|
children: BTreeMap<u32, Vec<TreeChild>>,
|
||||||
|
blocks: BTreeMap<u32, Vec<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MerkleStore {
|
||||||
|
/// Creates an empty store with the standard root id.
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
root_id: ROOT_NODE,
|
||||||
|
children: BTreeMap::new(),
|
||||||
|
blocks: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deterministic root hash for the current tree contents.
|
||||||
|
pub(super) fn root_hash(&self) -> u32 {
|
||||||
|
self.node_hash(self.root_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns child summaries for `node_id` in stable order.
|
||||||
|
pub(super) fn child_summaries(&self, node_id: u32) -> Vec<ChildSummary> {
|
||||||
|
self.children
|
||||||
|
.get(&node_id)
|
||||||
|
.map(|children| {
|
||||||
|
children
|
||||||
|
.iter()
|
||||||
|
.map(|child| ChildSummary {
|
||||||
|
id: child.id,
|
||||||
|
kind: child.kind,
|
||||||
|
hash: self.hash_for(child.kind, child.id),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local hash for a branch or block child.
|
||||||
|
pub(super) fn hash_for(&self, kind: ChildKind, id: u32) -> u32 {
|
||||||
|
match kind {
|
||||||
|
ChildKind::Branch => self.node_hash(id),
|
||||||
|
ChildKind::Block => self.block_hash(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored chunks for a block, preserving stream order.
|
||||||
|
pub(super) fn block_chunks(&self, block_id: u32) -> Vec<Vec<u8>> {
|
||||||
|
self.blocks.get(&block_id).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces one local block after a complete block stream arrives.
|
||||||
|
pub(super) fn replace_block(&mut self, block_id: u32, chunks: Vec<Vec<u8>>) {
|
||||||
|
self.blocks.insert(block_id, chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a deterministic hash for a branch node.
|
||||||
|
fn node_hash(&self, node_id: u32) -> u32 {
|
||||||
|
let mut hash = mix_u32(0x4E4F_4445, node_id);
|
||||||
|
|
||||||
|
if let Some(children) = self.children.get(&node_id) {
|
||||||
|
for child in children {
|
||||||
|
hash = mix_u32(hash, child.id);
|
||||||
|
hash = mix_u32(hash, child.kind.discriminant());
|
||||||
|
hash = mix_u32(hash, self.hash_for(child.kind, child.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a deterministic hash for a data block.
|
||||||
|
fn block_hash(&self, block_id: u32) -> u32 {
|
||||||
|
let mut hash = mix_u32(0x424C_4F43, block_id);
|
||||||
|
|
||||||
|
if let Some(chunks) = self.blocks.get(&block_id) {
|
||||||
|
for chunk in chunks {
|
||||||
|
hash = mix_u32(hash, chunk.len() as u32);
|
||||||
|
hash = hash_bytes(hash, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildKind {
|
||||||
|
/// Stable wire discriminant for streamed child summaries.
|
||||||
|
pub(super) fn discriminant(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
ChildKind::Branch => 0,
|
||||||
|
ChildKind::Block => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a stable wire discriminant.
|
||||||
|
pub(super) fn from_discriminant(value: u32) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
0 => Some(Self::Branch),
|
||||||
|
1 => Some(Self::Block),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remote store containing the authoritative content.
|
||||||
|
pub(super) fn remote_fixture() -> MerkleStore {
|
||||||
|
let mut store = base_tree();
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_ALPHA, chunks(&["alpha-", "same"]));
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_BRAVO, chunks(&["bravo-", "remote-", "v2"]));
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_CHARLIE, chunks(&["charlie-", "remote"]));
|
||||||
|
store.blocks.insert(BLOCK_DELTA, chunks(&["delta-same"]));
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local store with two stale blocks and two already matching blocks.
|
||||||
|
pub(super) fn local_fixture() -> MerkleStore {
|
||||||
|
let mut store = base_tree();
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_ALPHA, chunks(&["alpha-", "same"]));
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_BRAVO, chunks(&["bravo-", "local-", "v1"]));
|
||||||
|
store
|
||||||
|
.blocks
|
||||||
|
.insert(BLOCK_CHARLIE, chunks(&["charlie-", "local"]));
|
||||||
|
store.blocks.insert(BLOCK_DELTA, chunks(&["delta-same"]));
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tree topology shared by the local and remote fixtures.
|
||||||
|
fn base_tree() -> MerkleStore {
|
||||||
|
let mut store = MerkleStore::new();
|
||||||
|
store.children.insert(
|
||||||
|
ROOT_NODE,
|
||||||
|
vec![
|
||||||
|
TreeChild {
|
||||||
|
id: BRANCH_LEFT,
|
||||||
|
kind: ChildKind::Branch,
|
||||||
|
},
|
||||||
|
TreeChild {
|
||||||
|
id: BRANCH_RIGHT,
|
||||||
|
kind: ChildKind::Branch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
store.children.insert(
|
||||||
|
BRANCH_LEFT,
|
||||||
|
vec![
|
||||||
|
TreeChild {
|
||||||
|
id: BLOCK_ALPHA,
|
||||||
|
kind: ChildKind::Block,
|
||||||
|
},
|
||||||
|
TreeChild {
|
||||||
|
id: BLOCK_BRAVO,
|
||||||
|
kind: ChildKind::Block,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
store.children.insert(
|
||||||
|
BRANCH_RIGHT,
|
||||||
|
vec![
|
||||||
|
TreeChild {
|
||||||
|
id: BLOCK_CHARLIE,
|
||||||
|
kind: ChildKind::Block,
|
||||||
|
},
|
||||||
|
TreeChild {
|
||||||
|
id: BLOCK_DELTA,
|
||||||
|
kind: ChildKind::Block,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts string slices into owned byte chunks.
|
||||||
|
fn chunks(parts: &[&str]) -> Vec<Vec<u8>> {
|
||||||
|
parts.iter().map(|part| part.as_bytes().to_vec()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-like byte mixing used only for deterministic test hashes.
|
||||||
|
fn hash_bytes(mut hash: u32, bytes: &[u8]) -> u32 {
|
||||||
|
for byte in bytes {
|
||||||
|
hash ^= u32::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(16_777_619);
|
||||||
|
}
|
||||||
|
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixes one little-endian integer into the deterministic test hash.
|
||||||
|
fn mix_u32(hash: u32, value: u32) -> u32 {
|
||||||
|
hash_bytes(hash, &value.to_le_bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
mod streams;
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use crate::protocol::{Endpoint, EndpointError, RouteDirection};
|
||||||
|
|
||||||
|
use alloc::{boxed::Box, vec};
|
||||||
|
|
||||||
|
use support::{
|
||||||
|
CommsLeaf, ControllerLeaf, ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, ResponderLeaf,
|
||||||
|
assert_hook_present, assert_hook_removed, echo_packet, echo_packet_with_end, endpoint_at,
|
||||||
|
single_inbound_packet, single_outbound_packet,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oneshot() {
|
||||||
|
let (tx_a, rx_a) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_b, rx_b) = crossbeam_channel::unbounded();
|
||||||
|
|
||||||
|
let mut endpoint_a = Endpoint::new(
|
||||||
|
ENDPOINT_A,
|
||||||
|
vec![
|
||||||
|
Box::new(ControllerLeaf { has_run: false }),
|
||||||
|
Box::new(CommsLeaf {
|
||||||
|
tx: tx_b,
|
||||||
|
rx: rx_a,
|
||||||
|
remote_id: ENDPOINT_B,
|
||||||
|
is_authority: false,
|
||||||
|
started: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_a.path = vec![ENDPOINT_A];
|
||||||
|
|
||||||
|
let mut endpoint_b = Endpoint::new(
|
||||||
|
ENDPOINT_B,
|
||||||
|
vec![
|
||||||
|
Box::new(ResponderLeaf),
|
||||||
|
Box::new(CommsLeaf {
|
||||||
|
tx: tx_a,
|
||||||
|
rx: rx_b,
|
||||||
|
remote_id: ENDPOINT_A,
|
||||||
|
is_authority: true,
|
||||||
|
started: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B];
|
||||||
|
|
||||||
|
// Connections are registered routing state. The comms leaves also insert them
|
||||||
|
// during updates, but the first application packet should not depend on leaf order.
|
||||||
|
endpoint_a.connections.insert((ENDPOINT_B, false));
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
// Cycle 1: A sends request to B
|
||||||
|
endpoint_a.update();
|
||||||
|
endpoint_b.update();
|
||||||
|
|
||||||
|
// Cycle 2: B receives request and sends response to A
|
||||||
|
endpoint_b.update();
|
||||||
|
endpoint_a.update();
|
||||||
|
|
||||||
|
// Cycle 3: A's CommsLeaf needs one more update to pull the packet from the channel
|
||||||
|
// and put it into the inbound queue.
|
||||||
|
endpoint_a.update();
|
||||||
|
|
||||||
|
// Assertions on state
|
||||||
|
assert!(
|
||||||
|
endpoint_a.inbound.contains_key(&ENDPOINT_A),
|
||||||
|
"Endpoint A should have received response"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_a.inbound.get(&ENDPOINT_A).unwrap().len(),
|
||||||
|
1,
|
||||||
|
"Endpoint A should have exactly one packet"
|
||||||
|
);
|
||||||
|
let response = &endpoint_a
|
||||||
|
.inbound
|
||||||
|
.get(&ENDPOINT_A)
|
||||||
|
.unwrap()
|
||||||
|
.front()
|
||||||
|
.unwrap();
|
||||||
|
assert!(response.end_hook);
|
||||||
|
assert_eq!(response.data, "ABC123".as_bytes());
|
||||||
|
assert!(
|
||||||
|
endpoint_b.hook_count() == 0,
|
||||||
|
"responder hook should be cleaned after the upward response"
|
||||||
|
);
|
||||||
|
// assert_eq!(response.hook_id, HOOK_ECHO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inbound_downward_packet_for_local_endpoint_opens_hook() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_A,
|
||||||
|
echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let packet = single_inbound_packet(&endpoint, ENDPOINT_B);
|
||||||
|
assert!(!packet.end_hook);
|
||||||
|
assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_A));
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outbound_packet_for_local_endpoint_is_delivered_locally() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let packet = single_inbound_packet(&endpoint, ENDPOINT_B);
|
||||||
|
assert!(!packet.end_hook);
|
||||||
|
assert_eq!(packet.data, "ABC123".as_bytes());
|
||||||
|
assert_hook_removed(&endpoint, hook_id);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inbound_downward_packet_routes_to_immediate_child() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
endpoint.connections.insert((ENDPOINT_C, false));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_A,
|
||||||
|
echo_packet(vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], hook_id),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let packet = single_outbound_packet(&endpoint, ENDPOINT_C);
|
||||||
|
assert!(!packet.end_hook);
|
||||||
|
assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]);
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_C));
|
||||||
|
assert!(!endpoint.outbound.contains_key(&ENDPOINT_A));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outbound_downward_packet_routes_to_immediate_child() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_B);
|
||||||
|
endpoint.connections.insert((ENDPOINT_B, false));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(
|
||||||
|
vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C],
|
||||||
|
hook_id,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let packet = single_outbound_packet(&endpoint, ENDPOINT_B);
|
||||||
|
assert!(packet.end_hook);
|
||||||
|
assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]);
|
||||||
|
assert_hook_removed(&endpoint, hook_id);
|
||||||
|
assert!(!endpoint.outbound.contains_key(&ENDPOINT_C));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inbound_upward_packet_with_hook_routes_to_parent() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_C);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
endpoint.connections.insert((ENDPOINT_C, false));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_C,
|
||||||
|
echo_packet_with_end(vec![ENDPOINT_A], hook_id, true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let packet = single_outbound_packet(&endpoint, ENDPOINT_A);
|
||||||
|
assert!(packet.end_hook);
|
||||||
|
assert_eq!(packet.hook_id, hook_id);
|
||||||
|
assert_hook_removed(&endpoint, hook_id);
|
||||||
|
assert!(!endpoint.outbound.contains_key(&ENDPOINT_C));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inbound_upward_packet_without_hook_is_rejected() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
endpoint.connections.insert((ENDPOINT_C, false));
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_C,
|
||||||
|
echo_packet_with_end(vec![ENDPOINT_A], hook_id, true),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == hook_id
|
||||||
|
));
|
||||||
|
assert!(endpoint.inbound.is_empty());
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forged_upward_packet_with_unknown_hook_is_rejected() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
endpoint.accept_hook(7, ENDPOINT_C);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
endpoint.connections.insert((ENDPOINT_C, false));
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_inbound_from(ENDPOINT_C, echo_packet_with_end(vec![ENDPOINT_A], 99, true))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, EndpointError::UnknownHook { hook_id: 99 }));
|
||||||
|
assert_hook_present(&endpoint, 7);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forged_sideways_packet_is_rejected_as_incorrect_path() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_A);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_A,
|
||||||
|
echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, EndpointError::DestinationOutsideLocalTree));
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
assert!(endpoint.inbound.is_empty());
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_frame_is_dropped_by_comms_leaf() {
|
||||||
|
let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_unused, _rx_unused) = crossbeam_channel::unbounded();
|
||||||
|
let mut endpoint = Endpoint::new(
|
||||||
|
ENDPOINT_B,
|
||||||
|
vec![Box::new(CommsLeaf {
|
||||||
|
tx: tx_unused,
|
||||||
|
rx: rx_for_endpoint,
|
||||||
|
remote_id: ENDPOINT_A,
|
||||||
|
is_authority: true,
|
||||||
|
started: false,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
endpoint.path = vec![ENDPOINT_A, ENDPOINT_B];
|
||||||
|
|
||||||
|
tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap();
|
||||||
|
endpoint.update();
|
||||||
|
|
||||||
|
assert!(endpoint.inbound.is_empty());
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_frame_does_not_block_following_valid_packet() {
|
||||||
|
let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_unused, _rx_unused) = crossbeam_channel::unbounded();
|
||||||
|
let hook_id = 42;
|
||||||
|
let mut endpoint = Endpoint::new(
|
||||||
|
ENDPOINT_B,
|
||||||
|
vec![Box::new(CommsLeaf {
|
||||||
|
tx: tx_unused,
|
||||||
|
rx: rx_for_endpoint,
|
||||||
|
remote_id: ENDPOINT_A,
|
||||||
|
is_authority: true,
|
||||||
|
started: false,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
endpoint.path = vec![ENDPOINT_A, ENDPOINT_B];
|
||||||
|
|
||||||
|
tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap();
|
||||||
|
tx_to_endpoint
|
||||||
|
.send(
|
||||||
|
echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)
|
||||||
|
.serialize()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
endpoint.update();
|
||||||
|
|
||||||
|
let packet = single_inbound_packet(&endpoint, ENDPOINT_B);
|
||||||
|
assert!(!packet.end_hook);
|
||||||
|
assert_eq!(packet.hook_id, hook_id);
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() {
|
||||||
|
let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_unused, _rx_unused) = crossbeam_channel::unbounded();
|
||||||
|
let mut endpoint = Endpoint::new(
|
||||||
|
ENDPOINT_B,
|
||||||
|
vec![Box::new(CommsLeaf {
|
||||||
|
tx: tx_unused,
|
||||||
|
rx: rx_for_endpoint,
|
||||||
|
remote_id: ENDPOINT_C,
|
||||||
|
is_authority: false,
|
||||||
|
started: false,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
endpoint.path = vec![ENDPOINT_A, ENDPOINT_B];
|
||||||
|
endpoint.accept_hook(7, ENDPOINT_C);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
tx_to_endpoint
|
||||||
|
.send(
|
||||||
|
echo_packet_with_end(vec![ENDPOINT_A], 12, true)
|
||||||
|
.serialize()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
endpoint.update();
|
||||||
|
|
||||||
|
assert_hook_present(&endpoint, 7);
|
||||||
|
assert!(endpoint.inbound.is_empty());
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upward_outbound_without_hook_is_rejected() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
endpoint.accept_hook(7, ENDPOINT_A);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
let new_hook = endpoint.get_hook_id();
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == new_hook
|
||||||
|
));
|
||||||
|
assert_hook_present(&endpoint, 7);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downward_outbound_without_hook_is_allowed() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]);
|
||||||
|
endpoint.connections.insert((ENDPOINT_B, false));
|
||||||
|
|
||||||
|
let new_hook = endpoint.get_hook_id();
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(endpoint.outbound.get(&ENDPOINT_B).unwrap().len(), 1);
|
||||||
|
assert_hook_present(&endpoint, new_hook);
|
||||||
|
assert_eq!(endpoint.hook_peer(new_hook), Some(ENDPOINT_B));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeper_upward_route_uses_parent_as_next_hop() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]);
|
||||||
|
let new_hook = endpoint.get_hook_id();
|
||||||
|
|
||||||
|
endpoint.accept_hook(new_hook, ENDPOINT_B);
|
||||||
|
endpoint.connections.insert((ENDPOINT_B, true));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(endpoint.outbound.contains_key(&ENDPOINT_B));
|
||||||
|
assert!(!endpoint.outbound.contains_key(&ENDPOINT_A));
|
||||||
|
assert_hook_removed(&endpoint, new_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downward_route_without_connection_is_rejected() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
EndpointError::MissingConnection {
|
||||||
|
next_hop: ENDPOINT_B,
|
||||||
|
direction: RouteDirection::Downward,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_hook_removed(&endpoint, hook_id);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upward_route_without_connection_is_rejected_even_with_hook() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_A);
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
EndpointError::MissingConnection {
|
||||||
|
next_hop: ENDPOINT_A,
|
||||||
|
direction: RouteDirection::Upward,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_hook_removes_hook_after_packet_is_queued() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_A);
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_hook_removed(&endpoint, hook_id);
|
||||||
|
assert_eq!(
|
||||||
|
single_outbound_packet(&endpoint, ENDPOINT_A).hook_id,
|
||||||
|
hook_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_end_hook_route_keeps_hook_state() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
endpoint.accept_hook(hook_id, ENDPOINT_A);
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
EndpointError::MissingConnection {
|
||||||
|
next_hop: ENDPOINT_A,
|
||||||
|
direction: RouteDirection::Upward,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_hook_present(&endpoint, hook_id);
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inbound_without_absolute_path_is_rejected() {
|
||||||
|
let mut endpoint = Endpoint::new(ENDPOINT_A, vec![]);
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_inbound(echo_packet(vec![ENDPOINT_A], 1))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, EndpointError::EndpointPathUnset));
|
||||||
|
assert!(endpoint.inbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outbound_without_absolute_path_is_rejected() {
|
||||||
|
let mut endpoint = Endpoint::new(ENDPOINT_A, vec![]);
|
||||||
|
|
||||||
|
let error = endpoint
|
||||||
|
.add_outbound(echo_packet(vec![ENDPOINT_A], 1))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, EndpointError::EndpointPathUnset));
|
||||||
|
assert!(endpoint.outbound.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
use crate::protocol::{Endpoint, Leaf, Packet};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
use crate::protocol::LeafMeta;
|
||||||
|
|
||||||
|
use alloc::{boxed::Box, format, vec, vec::Vec};
|
||||||
|
|
||||||
|
use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed};
|
||||||
|
|
||||||
|
const LEAF_STREAM_CALLER: u32 = 200;
|
||||||
|
const LEAF_STREAM_RESPONDENT: u32 = 201;
|
||||||
|
|
||||||
|
/// Builds the initial downwards packet that opens the stream on the respondent.
|
||||||
|
///
|
||||||
|
/// The request keeps `end_hook = false` because it expects a return stream. Downward
|
||||||
|
/// routing now paves that hook automatically at every endpoint that accepts or
|
||||||
|
/// forwards the request.
|
||||||
|
fn stream_open_packet(hook_id: u16) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook: false,
|
||||||
|
path: vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
procedure_id: 2,
|
||||||
|
data: b"open".to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one upward stream frame for a previously opened hook.
|
||||||
|
///
|
||||||
|
/// `end_hook` is false for every intermediate frame and true only for the final
|
||||||
|
/// frame. This is the behavior the routing layer relies on to keep hook state until
|
||||||
|
/// the stream has actually finished sending upward.
|
||||||
|
fn stream_frame_packet(hook_id: u16, index: usize, end_hook: bool) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
path: vec![ENDPOINT_A],
|
||||||
|
procedure_id: 3,
|
||||||
|
data: format!("stream-{index}").into_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller leaf that opens exactly one stream request.
|
||||||
|
///
|
||||||
|
/// Keeping the caller this small makes the per-loop stream assertions about
|
||||||
|
/// respondent behavior rather than caller retries. The allocated hook id is read
|
||||||
|
/// back from endpoint state because the counter may start at a randomized offset.
|
||||||
|
struct StreamCallerLeaf {
|
||||||
|
has_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respondent leaf that converts the first request into a one-way stream.
|
||||||
|
///
|
||||||
|
/// This mimics a leaf spawning stream state, not a new endpoint: once a request is
|
||||||
|
/// delivered locally, the leaf records the hook and emits at most one frame on each
|
||||||
|
/// later `update`. A failed route does not advance the stream, so retry behavior can
|
||||||
|
/// be tested by restoring the connection on a later loop.
|
||||||
|
struct StreamRespondentLeaf {
|
||||||
|
stream: Option<StreamState>,
|
||||||
|
total_packets: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight stream state owned by the respondent leaf.
|
||||||
|
///
|
||||||
|
/// The endpoint routing layer only knows hooks and packets. This leaf-level state is
|
||||||
|
/// the minimal application-side record needed to emit ordered frames one at a time.
|
||||||
|
struct StreamState {
|
||||||
|
hook_id: u16,
|
||||||
|
next_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamRespondentLeaf {
|
||||||
|
/// Creates a respondent that will emit `total_packets` stream frames.
|
||||||
|
fn new(total_packets: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
stream: None,
|
||||||
|
total_packets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for StreamCallerLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_STREAM_CALLER
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Stream Caller Leaf",
|
||||||
|
identifier: "dev.unshell.test.stream_caller_leaf",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if self.has_run {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
let _ = endpoint.add_outbound(stream_open_packet(hook_id));
|
||||||
|
self.has_run = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for StreamRespondentLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_STREAM_RESPONDENT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Stream Respondant Leaf",
|
||||||
|
identifier: "dev.unshell.test.stream_respondent_leaf",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
self.open_stream_from_pending_request(endpoint);
|
||||||
|
self.send_next_frame(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamRespondentLeaf {
|
||||||
|
/// Opens stream state from the first locally delivered request packet.
|
||||||
|
///
|
||||||
|
/// Downward request routing has already paved the hook before the packet reaches
|
||||||
|
/// this leaf. The leaf only owns stream ordering; endpoint routing owns hook
|
||||||
|
/// authorization and cleanup.
|
||||||
|
fn open_stream_from_pending_request(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if self.stream.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_id = endpoint.path.last().cloned().unwrap_or(0);
|
||||||
|
let mut opened_hook = None;
|
||||||
|
|
||||||
|
endpoint.take_inbound_clear(local_id, |packet| {
|
||||||
|
if opened_hook.is_none() {
|
||||||
|
opened_hook = Some(packet.hook_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(hook_id) = opened_hook {
|
||||||
|
self.stream = Some(StreamState {
|
||||||
|
hook_id,
|
||||||
|
next_index: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits at most one frame for the active stream.
|
||||||
|
///
|
||||||
|
/// The stream only advances after the routing layer accepts the packet. This is
|
||||||
|
/// important for final packets: a failed final route must leave hook state and
|
||||||
|
/// stream progress intact so the next loop can retry instead of silently losing
|
||||||
|
/// the end-of-stream marker.
|
||||||
|
fn send_next_frame(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
let Some(stream) = self.stream.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if stream.next_index >= self.total_packets {
|
||||||
|
self.stream = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = stream.next_index;
|
||||||
|
let end_hook = index + 1 == self.total_packets;
|
||||||
|
let packet = stream_frame_packet(stream.hook_id, index, end_hook);
|
||||||
|
|
||||||
|
if endpoint.add_outbound(packet).is_ok() {
|
||||||
|
stream.next_index += 1;
|
||||||
|
|
||||||
|
if end_hook {
|
||||||
|
self.stream = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two endpoint, four leaf stream harness.
|
||||||
|
///
|
||||||
|
/// Each endpoint has exactly one application leaf and one mock connection leaf. The
|
||||||
|
/// channel leaves are intentionally the same `CommsLeaf` used by the oneshot tests
|
||||||
|
/// so stream behavior exercises the same serialization and routing boundary.
|
||||||
|
fn stream_endpoints(total_packets: usize) -> (Endpoint, Endpoint) {
|
||||||
|
let (tx_a, rx_a) = crossbeam_channel::unbounded();
|
||||||
|
let (tx_b, rx_b) = crossbeam_channel::unbounded();
|
||||||
|
|
||||||
|
let mut endpoint_a = Endpoint::new(
|
||||||
|
ENDPOINT_A,
|
||||||
|
vec![
|
||||||
|
Box::new(StreamCallerLeaf { has_run: false }),
|
||||||
|
Box::new(CommsLeaf {
|
||||||
|
tx: tx_b,
|
||||||
|
rx: rx_a,
|
||||||
|
remote_id: ENDPOINT_B,
|
||||||
|
is_authority: false,
|
||||||
|
started: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_a.path = vec![ENDPOINT_A];
|
||||||
|
|
||||||
|
let mut endpoint_b = Endpoint::new(
|
||||||
|
ENDPOINT_B,
|
||||||
|
vec![
|
||||||
|
Box::new(StreamRespondentLeaf::new(total_packets)),
|
||||||
|
Box::new(CommsLeaf {
|
||||||
|
tx: tx_a,
|
||||||
|
rx: rx_b,
|
||||||
|
remote_id: ENDPOINT_A,
|
||||||
|
is_authority: true,
|
||||||
|
started: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B];
|
||||||
|
|
||||||
|
// Register routes before the first application packet so leaf order is not a
|
||||||
|
// hidden prerequisite for the initial request leaving endpoint A.
|
||||||
|
endpoint_a.connections.insert((ENDPOINT_B, false));
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
(endpoint_a, endpoint_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts the requested two-endpoint, four-leaf topology.
|
||||||
|
fn assert_four_leaf_topology(endpoint_a: &Endpoint, endpoint_b: &Endpoint) {
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_a.leaves.len(),
|
||||||
|
2,
|
||||||
|
"caller endpoint should have two leaves"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_b.leaves.len(),
|
||||||
|
2,
|
||||||
|
"respondent endpoint should have two leaves"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the initial request until it is queued locally on endpoint B.
|
||||||
|
fn deliver_stream_request(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) {
|
||||||
|
endpoint_a.update();
|
||||||
|
endpoint_b.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the single hook opened by the stream request on both endpoints.
|
||||||
|
///
|
||||||
|
/// The production counter intentionally does not promise that the first hook is
|
||||||
|
/// zero. Stream tests still need to prove that both endpoints agree on one routed
|
||||||
|
/// return channel, so this helper validates the topology and returns the actual id
|
||||||
|
/// allocated by `StreamCallerLeaf`.
|
||||||
|
fn opened_stream_hook_id(endpoint_a: &Endpoint, endpoint_b: &Endpoint) -> u16 {
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_a.hook_count(),
|
||||||
|
1,
|
||||||
|
"caller endpoint should have exactly one stream hook"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_b.hook_count(),
|
||||||
|
1,
|
||||||
|
"respondent endpoint should have exactly one stream hook"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (&caller_hook, &caller_peer) = endpoint_a
|
||||||
|
.hooks
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.expect("caller endpoint should expose the opened hook");
|
||||||
|
let (&respondent_hook, &respondent_peer) = endpoint_b
|
||||||
|
.hooks
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.expect("respondent endpoint should expose the opened hook");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
caller_hook, respondent_hook,
|
||||||
|
"stream endpoints should agree on the hook id"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
caller_peer, ENDPOINT_B,
|
||||||
|
"caller hook should route stream frames through endpoint B"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
respondent_peer, ENDPOINT_A,
|
||||||
|
"respondent hook should route stream frames back through endpoint A"
|
||||||
|
);
|
||||||
|
|
||||||
|
caller_hook
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives one respondent stream loop and delivers any produced frame to endpoint A.
|
||||||
|
fn drive_stream_loop(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) {
|
||||||
|
endpoint_b.update();
|
||||||
|
endpoint_a.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns stream packets that endpoint A has received so far.
|
||||||
|
fn received_stream_packets(endpoint: &Endpoint) -> Vec<&Packet> {
|
||||||
|
endpoint
|
||||||
|
.inbound
|
||||||
|
.get(&ENDPOINT_A)
|
||||||
|
.map(|queue| queue.iter().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies ordered stream payloads and final-frame markers.
|
||||||
|
fn assert_received_stream(
|
||||||
|
endpoint: &Endpoint,
|
||||||
|
expected_count: usize,
|
||||||
|
final_seen: bool,
|
||||||
|
expected_hook_id: u16,
|
||||||
|
) {
|
||||||
|
let packets = received_stream_packets(endpoint);
|
||||||
|
assert_eq!(packets.len(), expected_count);
|
||||||
|
|
||||||
|
for (index, packet) in packets.iter().enumerate() {
|
||||||
|
assert_eq!(packet.hook_id, expected_hook_id);
|
||||||
|
assert_eq!(packet.data, format!("stream-{index}").as_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
packet.end_hook,
|
||||||
|
final_seen && index + 1 == expected_count,
|
||||||
|
"only the last received packet should close the stream"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_directional_stream_returns_one_packet_per_loop() {
|
||||||
|
let total_packets = 3;
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = stream_endpoints(total_packets);
|
||||||
|
assert_four_leaf_topology(&endpoint_a, &endpoint_b);
|
||||||
|
|
||||||
|
deliver_stream_request(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b);
|
||||||
|
|
||||||
|
assert_received_stream(&endpoint_a, 0, false, stream_hook_id);
|
||||||
|
assert_hook_present(&endpoint_a, stream_hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, stream_hook_id);
|
||||||
|
|
||||||
|
for index in 0..total_packets {
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
let final_seen = index + 1 == total_packets;
|
||||||
|
|
||||||
|
assert_received_stream(&endpoint_a, index + 1, final_seen, stream_hook_id);
|
||||||
|
|
||||||
|
if final_seen {
|
||||||
|
assert_hook_removed(&endpoint_a, stream_hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, stream_hook_id);
|
||||||
|
} else {
|
||||||
|
assert_hook_present(&endpoint_a, stream_hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, stream_hook_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_does_not_emit_before_request_is_processed_by_respondent() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = stream_endpoints(2);
|
||||||
|
|
||||||
|
deliver_stream_request(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b);
|
||||||
|
|
||||||
|
assert_received_stream(&endpoint_a, 0, false, stream_hook_id);
|
||||||
|
assert!(endpoint_b.outbound.is_empty());
|
||||||
|
assert_hook_present(&endpoint_a, stream_hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, stream_hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_stops_after_final_packet() {
|
||||||
|
let total_packets = 2;
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = stream_endpoints(total_packets);
|
||||||
|
|
||||||
|
deliver_stream_request(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b);
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
assert_received_stream(&endpoint_a, total_packets, true, stream_hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, stream_hook_id);
|
||||||
|
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
assert_received_stream(&endpoint_a, total_packets, true, stream_hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, stream_hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_final_stream_route_keeps_hook_and_retries() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = stream_endpoints(1);
|
||||||
|
|
||||||
|
deliver_stream_request(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b);
|
||||||
|
endpoint_b.connections.remove(&(ENDPOINT_A, true));
|
||||||
|
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
assert_received_stream(&endpoint_a, 0, false, stream_hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, stream_hook_id);
|
||||||
|
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
drive_stream_loop(&mut endpoint_a, &mut endpoint_b);
|
||||||
|
|
||||||
|
assert_received_stream(&endpoint_a, 1, true, stream_hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, stream_hook_id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use crate::protocol::{Endpoint, Leaf, Packet};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
use crate::protocol::LeafMeta;
|
||||||
|
|
||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
|
||||||
|
pub(super) const ENDPOINT_A: u32 = 0;
|
||||||
|
pub(super) const ENDPOINT_B: u32 = 1;
|
||||||
|
pub(super) const ENDPOINT_C: u32 = 2;
|
||||||
|
|
||||||
|
const LEAF_CONTROLLER: u32 = 100;
|
||||||
|
const LEAF_COMMS: u32 = 101;
|
||||||
|
const LEAF_RESPONDER: u32 = 102;
|
||||||
|
|
||||||
|
/// Builds a test packet whose route is the only field varied by routing tests.
|
||||||
|
///
|
||||||
|
/// Keeping the payload stable makes each assertion about endpoint behavior rather
|
||||||
|
/// than packet construction, which is important because forged and malformed cases
|
||||||
|
/// should fail before any leaf-level procedure handling would matter.
|
||||||
|
pub(super) fn echo_packet(path: Vec<u32>, hook_id: u16) -> Packet {
|
||||||
|
echo_packet_with_end(path, hook_id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a test packet with an explicit hook-lifetime marker.
|
||||||
|
pub(super) fn echo_packet_with_end(path: Vec<u32>, hook_id: u16, end_hook: bool) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
path,
|
||||||
|
procedure_id: 1,
|
||||||
|
data: "ABC123".as_bytes().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a bare endpoint at a known absolute path.
|
||||||
|
///
|
||||||
|
/// Most routing tests do not need leaves; they only need the endpoint's local path,
|
||||||
|
/// connection table, and hook table. This helper keeps that setup explicit without
|
||||||
|
/// hiding the routing state that each test is validating.
|
||||||
|
pub(super) fn endpoint_at(id: u32, path: Vec<u32>) -> Endpoint {
|
||||||
|
let mut endpoint = Endpoint::new(id, vec![]);
|
||||||
|
endpoint.path = path;
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the only outbound packet queued for `next_hop`.
|
||||||
|
///
|
||||||
|
/// Routing bugs often show up as packets being sent to the final destination rather
|
||||||
|
/// than the immediate neighbor. Tests use this helper to assert both that exactly one
|
||||||
|
/// packet exists and that it was queued for the expected adjacent endpoint.
|
||||||
|
pub(super) fn single_outbound_packet(endpoint: &Endpoint, next_hop: u32) -> &Packet {
|
||||||
|
let queue = endpoint
|
||||||
|
.outbound
|
||||||
|
.get(&next_hop)
|
||||||
|
.unwrap_or_else(|| panic!("expected one outbound queue for {next_hop}"));
|
||||||
|
assert_eq!(queue.len(), 1, "expected exactly one outbound packet");
|
||||||
|
queue.front().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the only inbound packet delivered to `local_id`.
|
||||||
|
///
|
||||||
|
/// Local delivery is intentionally separate from transit forwarding, so the tests
|
||||||
|
/// assert against the local inbound queue instead of only checking that routing did
|
||||||
|
/// not produce an error.
|
||||||
|
pub(super) fn single_inbound_packet(endpoint: &Endpoint, local_id: u32) -> &Packet {
|
||||||
|
let queue = endpoint
|
||||||
|
.inbound
|
||||||
|
.get(&local_id)
|
||||||
|
.unwrap_or_else(|| panic!("expected one inbound queue for {local_id}"));
|
||||||
|
assert_eq!(queue.len(), 1, "expected exactly one inbound packet");
|
||||||
|
queue.front().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that local hook state still contains `hook_id`.
|
||||||
|
///
|
||||||
|
/// Tests use this instead of open-coded map checks so every lifecycle assertion
|
||||||
|
/// explains the intended routing invariant when it fails.
|
||||||
|
pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) {
|
||||||
|
assert!(
|
||||||
|
endpoint.has_hook(hook_id),
|
||||||
|
"expected hook {hook_id} to remain registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that local hook state no longer contains `hook_id`.
|
||||||
|
///
|
||||||
|
/// Upward `end_hook` packets are the only cases that should remove hook state;
|
||||||
|
/// downward and local packets with the same flag must leave hooks alone.
|
||||||
|
pub(super) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) {
|
||||||
|
assert!(
|
||||||
|
!endpoint.has_hook(hook_id),
|
||||||
|
"expected hook {hook_id} to be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ControllerLeaf {
|
||||||
|
pub(super) has_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct CommsLeaf {
|
||||||
|
pub(super) tx: Sender<Vec<u8>>,
|
||||||
|
pub(super) rx: Receiver<Vec<u8>>,
|
||||||
|
|
||||||
|
pub(super) remote_id: u32,
|
||||||
|
pub(super) is_authority: bool,
|
||||||
|
pub(super) started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ResponderLeaf;
|
||||||
|
|
||||||
|
impl Leaf for ControllerLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_CONTROLLER
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Controller Leaf",
|
||||||
|
identifier: "dev.unshell.test.controller_leaf",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if !self.has_run {
|
||||||
|
// The controller starts exactly one request so the end-to-end test can
|
||||||
|
// assert deterministic routing without accumulating retries.
|
||||||
|
let hook_id = endpoint.get_hook_id();
|
||||||
|
let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id);
|
||||||
|
let _ = endpoint.add_outbound(packet);
|
||||||
|
self.has_run = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for CommsLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_COMMS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Comms Leaf",
|
||||||
|
identifier: "dev.unshell.test.comms_leaf",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
if !self.started {
|
||||||
|
endpoint
|
||||||
|
.connections
|
||||||
|
.insert((self.remote_id, self.is_authority));
|
||||||
|
self.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !self.rx.is_empty() {
|
||||||
|
let data = self.rx.recv().unwrap();
|
||||||
|
|
||||||
|
// Transport bytes are untrusted. Dropping malformed frames here keeps
|
||||||
|
// the oneshot harness faithful to a router boundary: invalid wire data
|
||||||
|
// must not panic or poison later valid packets on the same connection.
|
||||||
|
if let Ok(packet) = Packet::deserialize(&data) {
|
||||||
|
let _ = endpoint.add_inbound_from(self.remote_id, packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.take_outbound_clear(self.remote_id, |packet| {
|
||||||
|
let data = packet.serialize().unwrap();
|
||||||
|
let _ = self.tx.send(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf for ResponderLeaf {
|
||||||
|
fn get_id(&self) -> u32 {
|
||||||
|
LEAF_RESPONDER
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
fn get_meta(&self) -> LeafMeta {
|
||||||
|
LeafMeta {
|
||||||
|
name: "Responder Leaf",
|
||||||
|
identifier: "dev.unshell.test.responder_leaf",
|
||||||
|
version: "v0",
|
||||||
|
authors: vec!["ASTATIN3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, endpoint: &mut Endpoint) {
|
||||||
|
let local_id = endpoint.path.last().cloned().unwrap_or(0);
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
|
||||||
|
endpoint.take_inbound_clear(local_id, |packet| {
|
||||||
|
let mut response = echo_packet_with_end(vec![ENDPOINT_A], packet.hook_id, true);
|
||||||
|
response.hook_id = packet.hook_id;
|
||||||
|
response.data = packet.data.clone();
|
||||||
|
packets.push(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
for packet in packets {
|
||||||
|
let _ = endpoint.add_outbound(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
|
||||||
|
use crate::protocol::{DeserializeError, EndpointError, Packet, SerializeError};
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_packet() -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id: 42,
|
||||||
|
end_hook: false,
|
||||||
|
path: vec![1, 2, 3],
|
||||||
|
procedure_id: 0xAABB_CCDD,
|
||||||
|
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_packet_flags(end_hook: bool) -> Packet {
|
||||||
|
Packet {
|
||||||
|
end_hook,
|
||||||
|
..make_packet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_len_offset(buf: &[u8]) -> usize {
|
||||||
|
let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize;
|
||||||
|
8 + (path_len * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn procedure_id_offset(buf: &[u8]) -> usize {
|
||||||
|
body_len_offset(buf) + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round-trip ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_round_trip() {
|
||||||
|
let packet = make_packet();
|
||||||
|
let buf = packet.serialize().unwrap();
|
||||||
|
let result = Packet::deserialize(&buf).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.hook_id, packet.hook_id);
|
||||||
|
assert_eq!(result.end_hook, packet.end_hook);
|
||||||
|
assert_eq!(result.path, packet.path);
|
||||||
|
assert_eq!(result.procedure_id, packet.procedure_id);
|
||||||
|
assert_eq!(result.data, packet.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn procedure_id_is_fixed_width_u32() {
|
||||||
|
let packet = make_packet();
|
||||||
|
let buf = packet.serialize().unwrap();
|
||||||
|
let proc_offset = procedure_id_offset(&buf);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&buf[proc_offset..proc_offset + 4],
|
||||||
|
&packet.procedure_id.to_le_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(&buf[proc_offset + 4..], packet.data.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flags ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_end_hook_false() {
|
||||||
|
let packet = make_packet_flags(false);
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert!(!result.end_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_end_hook_true() {
|
||||||
|
let packet = make_packet_flags(true);
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert!(result.end_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty fields ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_path() {
|
||||||
|
let packet = Packet {
|
||||||
|
path: vec![],
|
||||||
|
..make_packet()
|
||||||
|
};
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert_eq!(result.path, &[] as &[u32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_procedure_id() {
|
||||||
|
let packet = Packet {
|
||||||
|
procedure_id: 0,
|
||||||
|
..make_packet()
|
||||||
|
};
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert_eq!(result.procedure_id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_data() {
|
||||||
|
let packet = Packet {
|
||||||
|
data: vec![],
|
||||||
|
..make_packet()
|
||||||
|
};
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert_eq!(result.data, &[] as &[u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_fields_empty() {
|
||||||
|
let packet = Packet {
|
||||||
|
hook_id: 0,
|
||||||
|
end_hook: false,
|
||||||
|
path: vec![],
|
||||||
|
procedure_id: 0,
|
||||||
|
data: vec![],
|
||||||
|
};
|
||||||
|
let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap();
|
||||||
|
assert_eq!(result.hook_id, 0);
|
||||||
|
assert_eq!(result.path, Vec::<u32>::new());
|
||||||
|
assert_eq!(result.procedure_id, 0);
|
||||||
|
assert_eq!(result.data, &[] as &[u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Truncation / corruption ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_in_fixed_prefix() {
|
||||||
|
let buf = make_packet().serialize().unwrap();
|
||||||
|
// Cut inside the fixed 8-byte prefix.
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf[..4]).unwrap_err(),
|
||||||
|
DeserializeError::BufferTooShort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_in_path() {
|
||||||
|
let buf = make_packet().serialize().unwrap();
|
||||||
|
// Cut to just past the fixed prefix, mid-path.
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf[..9]).unwrap_err(),
|
||||||
|
DeserializeError::BufferTooShort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_before_body_len() {
|
||||||
|
let buf = make_packet().serialize().unwrap();
|
||||||
|
let body_len_offset = body_len_offset(&buf);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf[..body_len_offset + 2]).unwrap_err(),
|
||||||
|
DeserializeError::BufferTooShort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_in_body() {
|
||||||
|
let buf = make_packet().serialize().unwrap();
|
||||||
|
// Remove last byte — well into the body.
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf[..buf.len() - 1]).unwrap_err(),
|
||||||
|
DeserializeError::BodyLengthMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_buffer_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&[]).unwrap_err(),
|
||||||
|
DeserializeError::BufferTooShort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_length_mismatch_is_rejected() {
|
||||||
|
let mut buf = make_packet().serialize().unwrap();
|
||||||
|
let body_len_offset = body_len_offset(&buf);
|
||||||
|
let inflated_body_len = 999u32;
|
||||||
|
buf[body_len_offset..body_len_offset + 4].copy_from_slice(&inflated_body_len.to_le_bytes());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf).unwrap_err(),
|
||||||
|
DeserializeError::BodyLengthMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_too_short_for_procedure_id_is_rejected() {
|
||||||
|
let mut buf = make_packet().serialize().unwrap();
|
||||||
|
let body_len_offset = body_len_offset(&buf);
|
||||||
|
let short_body_len = 3u32;
|
||||||
|
buf[body_len_offset..body_len_offset + 4].copy_from_slice(&short_body_len.to_le_bytes());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packet::deserialize(&buf).unwrap_err(),
|
||||||
|
DeserializeError::BufferTooShort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_error_wraps_into_endpoint_error() {
|
||||||
|
let error: EndpointError = SerializeError::BodyTooLarge.into();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
EndpointError::PacketSerialize {
|
||||||
|
source: SerializeError::BodyTooLarge,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_error_wraps_into_endpoint_error() {
|
||||||
|
let error: EndpointError = DeserializeError::BufferTooShort.into();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
EndpointError::PacketDeserialize {
|
||||||
|
source: DeserializeError::BufferTooShort,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "unshell-leaves"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
description = "Application-layer UnShell leaves and client surfaces"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
leaf_endpoint = []
|
|
||||||
leaf_tui = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
rkyv = { workspace = true }
|
|
||||||
portable-pty = { workspace = true }
|
|
||||||
crossbeam-channel = { workspace = true }
|
|
||||||
unshell-macros = { workspace = true }
|
|
||||||
unshell-protocol = { workspace = true }
|
|
||||||
|
|
||||||
[lints.rust]
|
|
||||||
elided_lifetimes_in_paths = "warn"
|
|
||||||
future_incompatible = { level = "warn", priority = -1 }
|
|
||||||
nonstandard_style = { level = "warn", priority = -1 }
|
|
||||||
rust_2018_idioms = { level = "warn", priority = -1 }
|
|
||||||
rust_2021_prelude_collisions = "warn"
|
|
||||||
semicolon_in_expressions_from_macros = "warn"
|
|
||||||
unsafe_op_in_unsafe_fn = "warn"
|
|
||||||
unused_import_braces = "warn"
|
|
||||||
unused_lifetimes = "warn"
|
|
||||||
trivial_casts = "allow"
|
|
||||||
missing_docs = "warn"
|
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "unshell-protocol"
|
name = "leaf-pty"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Wire protocol, framing, validation, and endpoint runtime for UnShell"
|
description = "Hook-backed PTY leaf implementation for UnShell"
|
||||||
|
|
||||||
[lib]
|
|
||||||
doctest = false
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rkyv = { workspace = true }
|
unshell = { workspace = true }
|
||||||
unshell-macros = { path = "../unshell-macros" }
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
interface = ["unshell/interface"]
|
||||||
|
interface_ratatui = ["interface", "unshell/interface_ratatui"]
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
elided_lifetimes_in_paths = "warn"
|
elided_lifetimes_in_paths = "warn"
|
||||||
@@ -22,4 +23,3 @@ unsafe_op_in_unsafe_fn = "warn"
|
|||||||
unused_import_braces = "warn"
|
unused_import_braces = "warn"
|
||||||
unused_lifetimes = "warn"
|
unused_lifetimes = "warn"
|
||||||
trivial_casts = "allow"
|
trivial_casts = "allow"
|
||||||
missing_docs = "warn"
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use unshell::protocol::{HookID, Packet};
|
||||||
|
|
||||||
|
use crate::{OP_ERROR, OP_OPEN, PROC_PTY};
|
||||||
|
|
||||||
|
/// Encodes a tiny PTY frame into `Packet::data`.
|
||||||
|
pub fn encode_frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut data = Vec::with_capacity(1 + payload.len());
|
||||||
|
data.push(opcode);
|
||||||
|
data.extend_from_slice(payload);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes an `Open` payload with the caller's reply path.
|
||||||
|
pub fn encode_open(reply_path: &[u32]) -> Vec<u8> {
|
||||||
|
let mut data = Vec::with_capacity(2 + reply_path.len() * 4);
|
||||||
|
data.push(OP_OPEN);
|
||||||
|
data.push(reply_path.len() as u8);
|
||||||
|
|
||||||
|
for segment in reply_path {
|
||||||
|
data.extend_from_slice(&segment.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the reply path embedded in an `Open` payload after the opcode byte.
|
||||||
|
pub fn decode_open_reply_path(payload: &[u8]) -> Option<Vec<u32>> {
|
||||||
|
let path_len = usize::from(*payload.first()?);
|
||||||
|
let path_bytes = path_len.checked_mul(4)?;
|
||||||
|
let expected_len = 1usize.checked_add(path_bytes)?;
|
||||||
|
|
||||||
|
if payload.len() != expected_len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = Vec::with_capacity(path_len);
|
||||||
|
for chunk in payload[1..].chunks_exact(4) {
|
||||||
|
path.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opcode byte from a PTY packet, if present.
|
||||||
|
pub fn frame_opcode(packet: &Packet) -> Option<u8> {
|
||||||
|
packet.data.first().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the frame payload after the opcode byte.
|
||||||
|
pub fn frame_payload(packet: &Packet) -> &[u8] {
|
||||||
|
if packet.data.len() > 1 {
|
||||||
|
&packet.data[1..]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an outer PTY packet for callers and tests.
|
||||||
|
pub fn pty_packet(
|
||||||
|
path: Vec<u32>,
|
||||||
|
hook_id: HookID,
|
||||||
|
end_hook: bool,
|
||||||
|
opcode: u8,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
path,
|
||||||
|
procedure_id: PROC_PTY,
|
||||||
|
data: encode_frame(opcode, payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an outer PTY open packet with the specialized open payload shape.
|
||||||
|
pub fn pty_open_packet(path: Vec<u32>, hook_id: HookID, reply_path: &[u32]) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook: false,
|
||||||
|
path,
|
||||||
|
procedure_id: PROC_PTY,
|
||||||
|
data: encode_open(reply_path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a final error packet for session initialization failures.
|
||||||
|
pub(crate) fn error_packet(hook_id: HookID, reply_path: Vec<u32>, payload: &[u8]) -> Packet {
|
||||||
|
Packet {
|
||||||
|
hook_id,
|
||||||
|
end_hook: true,
|
||||||
|
path: reply_path,
|
||||||
|
procedure_id: PROC_PTY,
|
||||||
|
data: encode_frame(OP_ERROR, payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infers the caller reply path from a locally delivered destination path.
|
||||||
|
pub(crate) fn reply_path_from_destination(destination: &[u32]) -> Vec<u32> {
|
||||||
|
if destination.len() > 1 {
|
||||||
|
destination[..destination.len() - 1].to_vec()
|
||||||
|
} else {
|
||||||
|
destination.to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use unshell::hash_32;
|
||||||
|
|
||||||
|
/// Leaf id used by the generated fake PTY wrapper.
|
||||||
|
pub const LEAF_FAKE_PTY: u32 = hash_32!("dev.unshell.v1.pty");
|
||||||
|
|
||||||
|
/// Outer procedure id used by all fake PTY session packets.
|
||||||
|
pub const PROC_PTY: u32 = hash_32!("dev.unshell.v1.pty.pty");
|
||||||
|
|
||||||
|
/// Downward opcode that opens one PTY session.
|
||||||
|
pub const OP_OPEN: u8 = 0;
|
||||||
|
|
||||||
|
/// Upward opcode acknowledging an opened PTY session.
|
||||||
|
pub const OP_OPENED: u8 = 1;
|
||||||
|
|
||||||
|
/// Downward opcode carrying PTY stdin bytes.
|
||||||
|
pub const OP_INPUT: u8 = 2;
|
||||||
|
|
||||||
|
/// Downward opcode representing terminal resize.
|
||||||
|
pub const OP_RESIZE: u8 = 3;
|
||||||
|
|
||||||
|
/// Downward opcode closing PTY stdin without closing the session hook.
|
||||||
|
pub const OP_STDIN_EOF: u8 = 4;
|
||||||
|
|
||||||
|
/// Downward opcode asking the remote process to terminate gracefully.
|
||||||
|
pub const OP_TERMINATE: u8 = 5;
|
||||||
|
|
||||||
|
/// Downward opcode aborting the session without an acknowledgement.
|
||||||
|
pub const OP_ABORT: u8 = 6;
|
||||||
|
|
||||||
|
/// Upward opcode carrying PTY stdout/stderr bytes.
|
||||||
|
pub const OP_OUTPUT: u8 = 7;
|
||||||
|
|
||||||
|
/// Upward final opcode carrying the process exit status.
|
||||||
|
pub const OP_EXIT: u8 = 8;
|
||||||
|
|
||||||
|
/// Upward final opcode carrying a fatal PTY protocol error.
|
||||||
|
pub const OP_ERROR: u8 = 9;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! PTY leaf support for UnShell.
|
||||||
|
//!
|
||||||
|
//! This crate currently contains a deterministic fake PTY session used to prove the
|
||||||
|
//! macro-generated leaf shape. The fake leaf exercises the same hook-backed protocol
|
||||||
|
//! invariants as a real PTY worker without pulling OS-specific PTY code into
|
||||||
|
//! `unshell-protocol`.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
mod codec;
|
||||||
|
mod constants;
|
||||||
|
mod session;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub use codec::{
|
||||||
|
decode_open_reply_path, encode_frame, encode_open, frame_opcode, frame_payload,
|
||||||
|
pty_open_packet, pty_packet,
|
||||||
|
};
|
||||||
|
pub use constants::*;
|
||||||
|
pub use session::{PtySession, PtySessionState};
|
||||||
|
pub use state::{FakePtyLeaf, FakePtyState};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
use unshell::protocol::{
|
||||||
|
HookID, Packet, PacketQueue, Session, SessionCtx, SessionInit, SessionInitResult, SessionStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
codec::{
|
||||||
|
decode_open_reply_path, error_packet, frame_opcode, frame_payload,
|
||||||
|
reply_path_from_destination,
|
||||||
|
},
|
||||||
|
constants::{
|
||||||
|
OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPEN, OP_OPENED, OP_OUTPUT, OP_STDIN_EOF,
|
||||||
|
OP_TERMINATE, PROC_PTY,
|
||||||
|
},
|
||||||
|
state::FakePtyState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Session contract for one hook-backed fake PTY.
|
||||||
|
pub struct PtySession;
|
||||||
|
|
||||||
|
/// Per-hook fake PTY session state.
|
||||||
|
///
|
||||||
|
/// A real PTY leaf will replace the pending flags with a worker handle. The reply path
|
||||||
|
/// and hook lifecycle behavior should stay the same.
|
||||||
|
pub struct PtySessionState {
|
||||||
|
hook_id: HookID,
|
||||||
|
reply_path: Vec<u32>,
|
||||||
|
opened_pending: bool,
|
||||||
|
stdin_closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session<FakePtyState> for PtySession {
|
||||||
|
const PROCEDURE_ID: u32 = PROC_PTY;
|
||||||
|
|
||||||
|
type State = PtySessionState;
|
||||||
|
|
||||||
|
fn reply_path(session: &Self::State) -> &[u32] {
|
||||||
|
&session.reply_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(
|
||||||
|
leaf: &mut FakePtyState,
|
||||||
|
packet: Packet,
|
||||||
|
ctx: &mut SessionInit,
|
||||||
|
) -> SessionInitResult<Self::State> {
|
||||||
|
if frame_opcode(&packet) != Some(OP_OPEN) {
|
||||||
|
return SessionInitResult::RejectedWith(error_packet(
|
||||||
|
ctx.hook_id(),
|
||||||
|
reply_path_from_destination(ctx.packet_path()),
|
||||||
|
b"unknown-session",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply_path = decode_open_reply_path(frame_payload(&packet))
|
||||||
|
.unwrap_or_else(|| reply_path_from_destination(ctx.packet_path()));
|
||||||
|
|
||||||
|
leaf.active_count += 1;
|
||||||
|
leaf.total_opened += 1;
|
||||||
|
|
||||||
|
SessionInitResult::Created(PtySessionState {
|
||||||
|
hook_id: ctx.hook_id(),
|
||||||
|
reply_path,
|
||||||
|
opened_pending: true,
|
||||||
|
stdin_closed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
leaf: &mut FakePtyState,
|
||||||
|
session: &mut Self::State,
|
||||||
|
incoming: &mut PacketQueue,
|
||||||
|
ctx: &mut SessionCtx<'_>,
|
||||||
|
) -> SessionStatus {
|
||||||
|
if session.opened_pending {
|
||||||
|
ctx.send(OP_OPENED, &[]);
|
||||||
|
session.opened_pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(packet) = incoming.pop_front() {
|
||||||
|
match frame_opcode(&packet) {
|
||||||
|
Some(OP_INPUT) => ctx.send(OP_OUTPUT, frame_payload(&packet)),
|
||||||
|
Some(OP_STDIN_EOF) => {
|
||||||
|
session.stdin_closed = true;
|
||||||
|
leaf.last_stdin_eof_hook = Some(session.hook_id);
|
||||||
|
}
|
||||||
|
Some(OP_TERMINATE) => {
|
||||||
|
ctx.send_final(OP_EXIT, &[0]);
|
||||||
|
close_session(leaf);
|
||||||
|
return SessionStatus::Closed;
|
||||||
|
}
|
||||||
|
Some(OP_ABORT) => {
|
||||||
|
close_session(leaf);
|
||||||
|
return SessionStatus::Closed;
|
||||||
|
}
|
||||||
|
Some(OP_OPEN) => {
|
||||||
|
ctx.send_final(OP_ERROR, b"duplicate-open");
|
||||||
|
close_session(leaf);
|
||||||
|
return SessionStatus::Closed;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ctx.send_final(OP_ERROR, b"unknown-opcode");
|
||||||
|
close_session(leaf);
|
||||||
|
return SessionStatus::Closed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStatus::Running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrements the active-session counter exactly once for a terminal session path.
|
||||||
|
fn close_session(leaf: &mut FakePtyState) {
|
||||||
|
leaf.active_count = leaf.active_count.saturating_sub(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use unshell::protocol::{HookID, unshell_leaf};
|
||||||
|
|
||||||
|
use crate::{constants::LEAF_FAKE_PTY, session::PtySession};
|
||||||
|
|
||||||
|
/// User-owned state for the generated fake PTY leaf.
|
||||||
|
///
|
||||||
|
/// The `unshell_leaf!` template stores sessions and retry queues around this struct.
|
||||||
|
/// Keeping counters here makes tests and future procedures observe leaf behavior
|
||||||
|
/// without reaching into generated session storage.
|
||||||
|
pub struct FakePtyState {
|
||||||
|
/// Number of sessions that application logic considers active.
|
||||||
|
pub active_count: usize,
|
||||||
|
|
||||||
|
/// Total number of successfully opened sessions.
|
||||||
|
pub total_opened: u64,
|
||||||
|
|
||||||
|
/// Last hook that received stdin EOF.
|
||||||
|
pub last_stdin_eof_hook: Option<HookID>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakePtyState {
|
||||||
|
/// Creates a fake PTY state with no active sessions.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_count: 0,
|
||||||
|
total_opened: 0,
|
||||||
|
last_stdin_eof_hook: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FakePtyState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unshell_leaf! {
|
||||||
|
pub leaf FakePtyLeaf for FakePtyState {
|
||||||
|
id: LEAF_FAKE_PTY,
|
||||||
|
meta: unshell::protocol::LeafMeta {
|
||||||
|
name: "Fake PTY Leaf",
|
||||||
|
identifier: "dev.unshell.v1.pty",
|
||||||
|
version: "v0",
|
||||||
|
authors: unshell::alloc::vec!["ASTATIN3"],
|
||||||
|
},
|
||||||
|
sessions {
|
||||||
|
pty: PtySession,
|
||||||
|
}
|
||||||
|
procedures {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
|
||||||
|
use unshell::protocol::{Endpoint, Leaf, Packet};
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
use unshell::interface::{InterfaceEventKind, InterfaceStore, SessionKey, SessionViewStatus};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPENED, OP_OUTPUT,
|
||||||
|
OP_STDIN_EOF, OP_TERMINATE, PROC_PTY, frame_opcode, frame_payload, pty_open_packet, pty_packet,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENDPOINT_A: u32 = 0;
|
||||||
|
const ENDPOINT_B: u32 = 1;
|
||||||
|
const PROC_OTHER: u32 = 31;
|
||||||
|
|
||||||
|
/// Creates a bare endpoint at a known absolute path.
|
||||||
|
fn endpoint_at(id: u32, path: Vec<u32>) -> Endpoint {
|
||||||
|
let mut endpoint = Endpoint::new(id, vec![]);
|
||||||
|
endpoint.path = path;
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the parent/child endpoint pair used by PTY session tests.
|
||||||
|
fn pty_endpoints() -> (Endpoint, Endpoint) {
|
||||||
|
let mut endpoint_a = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]);
|
||||||
|
let mut endpoint_b = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
|
||||||
|
endpoint_a.connections.insert((ENDPOINT_B, false));
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
(endpoint_a, endpoint_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfers every queued packet for `next_hop` into `receiver` as `remote_id` traffic.
|
||||||
|
fn transfer_packets(sender: &mut Endpoint, receiver: &mut Endpoint, next_hop: u32, remote_id: u32) {
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
sender.take_outbound_clear(next_hop, |packet| packets.push(packet.clone()));
|
||||||
|
|
||||||
|
for packet in packets {
|
||||||
|
receiver.add_inbound_from(remote_id, packet).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one downward PTY frame from endpoint A to endpoint B.
|
||||||
|
fn send_downward_frame(
|
||||||
|
endpoint_a: &mut Endpoint,
|
||||||
|
endpoint_b: &mut Endpoint,
|
||||||
|
hook_id: u16,
|
||||||
|
opcode: u8,
|
||||||
|
payload: &[u8],
|
||||||
|
end_hook: bool,
|
||||||
|
) {
|
||||||
|
endpoint_a
|
||||||
|
.add_outbound(pty_packet(
|
||||||
|
vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
hook_id,
|
||||||
|
end_hook,
|
||||||
|
opcode,
|
||||||
|
payload,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a fake PTY session and delivers the `Opened` response to endpoint A.
|
||||||
|
fn open_pty_session(
|
||||||
|
endpoint_a: &mut Endpoint,
|
||||||
|
endpoint_b: &mut Endpoint,
|
||||||
|
leaf: &mut FakePtyLeaf,
|
||||||
|
) -> u16 {
|
||||||
|
let hook_id = endpoint_a.get_hook_id();
|
||||||
|
endpoint_a
|
||||||
|
.add_outbound(pty_open_packet(
|
||||||
|
vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
hook_id,
|
||||||
|
&[ENDPOINT_A],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A);
|
||||||
|
leaf.update(endpoint_b);
|
||||||
|
transfer_packets(endpoint_b, endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
|
||||||
|
hook_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains PTY packets delivered to endpoint A.
|
||||||
|
fn drain_parent_pty_packets(endpoint: &mut Endpoint) -> Vec<Packet> {
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
endpoint.take_inbound_matching(
|
||||||
|
ENDPOINT_A,
|
||||||
|
|packet| packet.procedure_id == PROC_PTY,
|
||||||
|
|packet| packets.push(packet),
|
||||||
|
);
|
||||||
|
packets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that local hook state still contains `hook_id`.
|
||||||
|
fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) {
|
||||||
|
assert!(endpoint.has_hook(hook_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that local hook state no longer contains `hook_id`.
|
||||||
|
fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) {
|
||||||
|
assert!(!endpoint.has_hook(hook_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that `packet` carries the expected PTY frame.
|
||||||
|
fn assert_frame(packet: &Packet, hook_id: u16, opcode: u8, end_hook: bool, payload: &[u8]) {
|
||||||
|
assert_eq!(packet.hook_id, hook_id);
|
||||||
|
assert_eq!(packet.end_hook, end_hook);
|
||||||
|
assert_eq!(frame_opcode(packet), Some(opcode));
|
||||||
|
assert_eq!(frame_payload(packet), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when `packets` contains the requested frame.
|
||||||
|
fn has_frame(packets: &[Packet], hook_id: u16, opcode: u8, payload: &[u8]) -> bool {
|
||||||
|
packets.iter().any(|packet| {
|
||||||
|
packet.hook_id == hook_id
|
||||||
|
&& frame_opcode(packet) == Some(opcode)
|
||||||
|
&& frame_payload(packet) == payload
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_pty_paves_hook_and_creates_session() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 1);
|
||||||
|
assert_eq!(leaf.state().active_count, 1);
|
||||||
|
assert_eq!(leaf.state().total_opened, 1);
|
||||||
|
assert_hook_present(&endpoint_a, hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, hook_id);
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_OPENED, false, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_and_output_share_one_hook() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_INPUT,
|
||||||
|
b"hello",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_OUTPUT, false, b"hello");
|
||||||
|
assert_hook_present(&endpoint_a, hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_eof_keeps_hook_until_exit() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_STDIN_EOF,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
|
||||||
|
assert_eq!(leaf.state().last_stdin_eof_hook, Some(hook_id));
|
||||||
|
assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty());
|
||||||
|
assert_hook_present(&endpoint_a, hook_id);
|
||||||
|
assert_hook_present(&endpoint_b, hook_id);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_TERMINATE,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]);
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert_hook_removed(&endpoint_a, hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_end_hook_cleans_route_and_session() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_TERMINATE,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]);
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert_hook_removed(&endpoint_a, hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_final_exit_route_retries_without_losing_session() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_TERMINATE,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
endpoint_b.connections.remove(&(ENDPOINT_A, true));
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 1);
|
||||||
|
assert_eq!(leaf.pending_packet_count(), 1);
|
||||||
|
assert_hook_present(&endpoint_b, hook_id);
|
||||||
|
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]);
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert_hook_removed(&endpoint_a, hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort_downward_end_hook_closes_without_ack() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_ABORT,
|
||||||
|
&[],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty());
|
||||||
|
assert_hook_removed(&endpoint_a, hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_session_input_returns_error_end_hook() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let hook_id = endpoint_a.get_hook_id();
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_INPUT,
|
||||||
|
b"orphan",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_ERROR, true, b"unknown-session");
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert_hook_removed(&endpoint_a, hook_id);
|
||||||
|
assert_hook_removed(&endpoint_b, hook_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_pty_sessions_interleave_without_crossing_hooks() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
|
||||||
|
let first_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
let second_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
second_hook,
|
||||||
|
OP_INPUT,
|
||||||
|
b"second",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
first_hook,
|
||||||
|
OP_INPUT,
|
||||||
|
b"first",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
leaf.update(&mut endpoint_b);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 2);
|
||||||
|
assert_eq!(packets.len(), 2);
|
||||||
|
assert!(has_frame(&packets, first_hook, OP_OUTPUT, b"first"));
|
||||||
|
assert!(has_frame(&packets, second_hook, OP_OUTPUT, b"second"));
|
||||||
|
assert_hook_present(&endpoint_a, first_hook);
|
||||||
|
assert_hook_present(&endpoint_a, second_hook);
|
||||||
|
assert_hook_present(&endpoint_b, first_hook);
|
||||||
|
assert_hook_present(&endpoint_b, second_hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pty_leaf_does_not_consume_other_leaf_packets() {
|
||||||
|
let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]);
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
endpoint.connections.insert((ENDPOINT_A, true));
|
||||||
|
|
||||||
|
endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_A,
|
||||||
|
pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], 7, &[ENDPOINT_A]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
endpoint
|
||||||
|
.add_inbound_from(
|
||||||
|
ENDPOINT_A,
|
||||||
|
Packet {
|
||||||
|
hook_id: 8,
|
||||||
|
end_hook: false,
|
||||||
|
path: vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
procedure_id: PROC_OTHER,
|
||||||
|
data: b"leave-me".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
leaf.update(&mut endpoint);
|
||||||
|
|
||||||
|
let mut other_packets = Vec::new();
|
||||||
|
endpoint.take_inbound_matching(
|
||||||
|
ENDPOINT_B,
|
||||||
|
|packet| packet.procedure_id == PROC_OTHER,
|
||||||
|
|packet| other_packets.push(packet),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 1);
|
||||||
|
assert_eq!(other_packets.len(), 1);
|
||||||
|
assert_eq!(other_packets[0].procedure_id, PROC_OTHER);
|
||||||
|
assert_eq!(other_packets[0].data, b"leave-me".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
#[test]
|
||||||
|
fn interface_update_records_session_flow() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let mut interface = InterfaceStore::new();
|
||||||
|
let hook_id = endpoint_a.get_hook_id();
|
||||||
|
|
||||||
|
endpoint_a
|
||||||
|
.add_outbound(pty_open_packet(
|
||||||
|
vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
hook_id,
|
||||||
|
&[ENDPOINT_A],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
transfer_packets(&mut endpoint_a, &mut endpoint_b, ENDPOINT_B, ENDPOINT_A);
|
||||||
|
|
||||||
|
leaf.update_interface(&mut endpoint_b, &mut interface);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 1);
|
||||||
|
assert!(interface.events().iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
&event.kind,
|
||||||
|
InterfaceEventKind::SessionCreated { hook_id: recorded_hook, .. }
|
||||||
|
if *recorded_hook == hook_id
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(interface.events().iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
&event.kind,
|
||||||
|
InterfaceEventKind::RouteSuccess { packet }
|
||||||
|
if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_OPENED)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "interface")]
|
||||||
|
#[test]
|
||||||
|
fn interface_update_records_failed_final_route_without_dropping_session() {
|
||||||
|
let (mut endpoint_a, mut endpoint_b) = pty_endpoints();
|
||||||
|
let mut leaf = FakePtyLeaf::new(FakePtyState::new());
|
||||||
|
let mut interface = InterfaceStore::new();
|
||||||
|
let hook_id = endpoint_a.get_hook_id();
|
||||||
|
|
||||||
|
endpoint_a
|
||||||
|
.add_outbound(pty_open_packet(
|
||||||
|
vec![ENDPOINT_A, ENDPOINT_B],
|
||||||
|
hook_id,
|
||||||
|
&[ENDPOINT_A],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
transfer_packets(&mut endpoint_a, &mut endpoint_b, ENDPOINT_B, ENDPOINT_A);
|
||||||
|
leaf.update_interface(&mut endpoint_b, &mut interface);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
send_downward_frame(
|
||||||
|
&mut endpoint_a,
|
||||||
|
&mut endpoint_b,
|
||||||
|
hook_id,
|
||||||
|
OP_TERMINATE,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
endpoint_b.connections.remove(&(ENDPOINT_A, true));
|
||||||
|
leaf.update_interface(&mut endpoint_b, &mut interface);
|
||||||
|
|
||||||
|
let session_key = SessionKey {
|
||||||
|
leaf_id: leaf.get_id(),
|
||||||
|
procedure_id: PROC_PTY,
|
||||||
|
hook_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 1);
|
||||||
|
assert_eq!(leaf.pending_packet_count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
interface.session_views().get(&session_key).unwrap().status,
|
||||||
|
SessionViewStatus::Closed
|
||||||
|
);
|
||||||
|
assert!(interface.events().iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
&event.kind,
|
||||||
|
InterfaceEventKind::RouteFailure { packet, .. }
|
||||||
|
if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_EXIT)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
endpoint_b.connections.insert((ENDPOINT_A, true));
|
||||||
|
leaf.update_interface(&mut endpoint_b, &mut interface);
|
||||||
|
transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B);
|
||||||
|
let packets = drain_parent_pty_packets(&mut endpoint_a);
|
||||||
|
|
||||||
|
assert_eq!(leaf.active_session_count(), 0);
|
||||||
|
assert_eq!(packets.len(), 1);
|
||||||
|
assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]);
|
||||||
|
assert!(interface.events().iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
&event.kind,
|
||||||
|
InterfaceEventKind::RouteSuccess { packet }
|
||||||
|
if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_EXIT)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
//! Crossbeam-channel-backed router leaf for in-process protocol simulations.
|
|
||||||
//!
|
|
||||||
//! This leaf owns parent/child transport links backed by `crossbeam_channel`, so
|
|
||||||
//! tests and examples can exercise full packet routing without opening real
|
|
||||||
//! sockets.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crossbeam_channel::Sender;
|
|
||||||
use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
use unshell_protocol::FrameBytes;
|
|
||||||
use unshell_protocol::tree::{
|
|
||||||
CallLeaf, ChildRoute, Endpoint, Ingress, ProtocolEndpoint, RouterLeaf,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{leaf, procedures};
|
|
||||||
|
|
||||||
/// One inbound frame delivered across a simulated channel hop.
|
|
||||||
///
|
|
||||||
/// What it is: the transport envelope sent between in-process nodes when this
|
|
||||||
/// leaf forwards protocol traffic over `crossbeam_channel`.
|
|
||||||
///
|
|
||||||
/// Why it exists: routing needs both the encoded frame bytes and the ingress side
|
|
||||||
/// that the receiver should apply when validating source paths.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::crossbeam_channel::CrossbeamEnvelope;
|
|
||||||
/// use unshell_leaves::protocol::{FrameBytes, tree::Ingress};
|
|
||||||
/// let envelope = CrossbeamEnvelope {
|
|
||||||
/// ingress: Ingress::Parent,
|
|
||||||
/// frame: FrameBytes::new(),
|
|
||||||
/// };
|
|
||||||
/// assert!(matches!(envelope.ingress, Ingress::Parent));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CrossbeamEnvelope {
|
|
||||||
/// Which side of the tree the receiving endpoint should treat this frame as coming from.
|
|
||||||
pub ingress: Ingress,
|
|
||||||
/// Encoded protocol frame bytes.
|
|
||||||
pub frame: FrameBytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request payload for promoting or pruning one simulated connection.
|
|
||||||
///
|
|
||||||
/// What it is: the protocol payload shared by the `add_connection` and
|
|
||||||
/// `remove_connection` procedures.
|
|
||||||
///
|
|
||||||
/// Why it exists: the leaf only needs the peer endpoint path to decide whether the
|
|
||||||
/// connection is a direct parent edge or a direct child edge.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::crossbeam_channel::ConnectionRequest;
|
|
||||||
/// let request = ConnectionRequest {
|
|
||||||
/// peer_path: vec!["agent".into(), "child".into()],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(request.peer_path.len(), 2);
|
|
||||||
/// ```
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ConnectionRequest {
|
|
||||||
/// Absolute endpoint path of the peer connection being managed.
|
|
||||||
pub peer_path: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Machine-readable snapshot of the leaf's active simulated connections.
|
|
||||||
///
|
|
||||||
/// What it is: the reply payload returned by `get_connections`, `add_connection`,
|
|
||||||
/// and `remove_connection`.
|
|
||||||
///
|
|
||||||
/// Why it exists: connection-management procedures should return the resulting
|
|
||||||
/// topology immediately so tests and tooling can confirm what changed.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::crossbeam_channel::ConnectionSnapshot;
|
|
||||||
/// let snapshot = ConnectionSnapshot {
|
|
||||||
/// parent: Some(vec!["agent".into()]),
|
|
||||||
/// children: vec![vec!["agent".into(), "child".into()]],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(snapshot.children.len(), 1);
|
|
||||||
/// ```
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ConnectionSnapshot {
|
|
||||||
/// The direct parent path, if this endpoint currently has one.
|
|
||||||
pub parent: Option<Vec<String>>,
|
|
||||||
/// The currently active direct child paths.
|
|
||||||
pub children: Vec<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors surfaced by the channel-backed router leaf.
|
|
||||||
///
|
|
||||||
/// What it is: the small, deterministic error set used by both the management
|
|
||||||
/// procedures and the transport forwarding hooks.
|
|
||||||
///
|
|
||||||
/// Why it exists: tests and examples need structured failures when a staged link is
|
|
||||||
/// missing, a path is not a direct neighbor, or a channel is already closed.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::crossbeam_channel::CrossbeamChannelError;
|
|
||||||
/// let error = CrossbeamChannelError::MissingStagedConnection;
|
|
||||||
/// assert_eq!(error.to_string(), "missing staged connection");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CrossbeamChannelError {
|
|
||||||
/// The requested peer path does not have a staged sender ready to activate.
|
|
||||||
MissingStagedConnection,
|
|
||||||
/// The requested peer path is neither the direct parent nor a direct child.
|
|
||||||
InvalidPeerPath,
|
|
||||||
/// No active parent link exists for upstream forwarding.
|
|
||||||
MissingParentConnection,
|
|
||||||
/// No active child link exists for the requested child path.
|
|
||||||
MissingChildConnection,
|
|
||||||
/// The receiving side of the channel is already disconnected.
|
|
||||||
ChannelClosed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::fmt::Display for CrossbeamChannelError {
|
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::MissingStagedConnection => f.write_str("missing staged connection"),
|
|
||||||
Self::InvalidPeerPath => f.write_str("peer path is not a direct parent or child"),
|
|
||||||
Self::MissingParentConnection => f.write_str("missing parent connection"),
|
|
||||||
Self::MissingChildConnection => f.write_str("missing child connection"),
|
|
||||||
Self::ChannelClosed => f.write_str("channel receiver is disconnected"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::error::Error for CrossbeamChannelError {}
|
|
||||||
|
|
||||||
/// Shared compile-time declaration for the crossbeam-channel router leaf.
|
|
||||||
///
|
|
||||||
/// What it is: the public leaf declaration that owns the canonical leaf name and
|
|
||||||
/// exported management procedure ids for [`CrossbeamChannelLeaf`].
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint code, examples, and tests should all derive the same
|
|
||||||
/// protocol-facing metadata from one source of truth instead of hand-assembling
|
|
||||||
/// the leaf id and procedure inventory.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::crossbeam_channel::CrossbeamChannel;
|
|
||||||
/// assert!(CrossbeamChannel::protocol_leaf_name().contains("crossbeam_channel"));
|
|
||||||
/// ```
|
|
||||||
#[leaf(
|
|
||||||
id = "org.unshell.v1.crossbeam_channel",
|
|
||||||
endpoint_struct = CrossbeamChannelLeaf,
|
|
||||||
procedures = ["add_connection", "remove_connection", "get_connections"]
|
|
||||||
)]
|
|
||||||
pub struct CrossbeamChannel;
|
|
||||||
|
|
||||||
/// In-process router leaf backed by `crossbeam_channel` senders.
|
|
||||||
///
|
|
||||||
/// What it is: a leaf host that stores one optional parent sender, any number of
|
|
||||||
/// child senders, and a staging area for connections that should only become live
|
|
||||||
/// after an explicit procedure call.
|
|
||||||
///
|
|
||||||
/// Why it exists: protocol tests need a realistic forwarding surface with parent
|
|
||||||
/// and child links, but opening TCP sockets would make those tests slower and more
|
|
||||||
/// brittle than necessary.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use crossbeam_channel::unbounded;
|
|
||||||
/// use unshell_leaves::crossbeam_channel::CrossbeamChannelLeaf;
|
|
||||||
/// let (tx, _rx) = unbounded();
|
|
||||||
/// let mut leaf = CrossbeamChannelLeaf::default();
|
|
||||||
/// let previous = leaf.stage_connection(vec!["agent".into()], tx);
|
|
||||||
/// assert!(previous.is_none());
|
|
||||||
/// ```
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CrossbeamChannelLeaf {
|
|
||||||
parent: Option<ChannelConnection>,
|
|
||||||
children: BTreeMap<Vec<String>, Sender<CrossbeamEnvelope>>,
|
|
||||||
child_routes: Vec<ChildRoute>,
|
|
||||||
staged: BTreeMap<Vec<String>, Sender<CrossbeamEnvelope>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct ChannelConnection {
|
|
||||||
path: Vec<String>,
|
|
||||||
sender: Sender<CrossbeamEnvelope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CrossbeamChannelLeaf {
|
|
||||||
/// Stages one channel sender so a later protocol procedure can activate it.
|
|
||||||
///
|
|
||||||
/// What it is: a bootstrap helper that prepares the transport handle before the
|
|
||||||
/// leaf promotes it into active routing state.
|
|
||||||
///
|
|
||||||
/// Why it exists: the sender itself is not a serializable protocol payload, so
|
|
||||||
/// tests and examples need a local way to install it before calling
|
|
||||||
/// `add_connection`.
|
|
||||||
pub fn stage_connection(
|
|
||||||
&mut self,
|
|
||||||
peer_path: Vec<String>,
|
|
||||||
sender: Sender<CrossbeamEnvelope>,
|
|
||||||
) -> Option<Sender<CrossbeamEnvelope>> {
|
|
||||||
self.staged.insert(peer_path, sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Promotes one staged connection into the active topology.
|
|
||||||
///
|
|
||||||
/// This is the same operation used by the public `add_connection` procedure,
|
|
||||||
/// but it is also useful for local bootstrap code that has not yet wired the
|
|
||||||
/// control plane needed to issue that call remotely.
|
|
||||||
pub fn connect_staged(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
peer_path: Vec<String>,
|
|
||||||
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
|
|
||||||
if !is_direct_parent(endpoint.path(), &peer_path)
|
|
||||||
&& !is_direct_child(endpoint.path(), &peer_path)
|
|
||||||
{
|
|
||||||
return Err(CrossbeamChannelError::InvalidPeerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(sender) = self.staged.remove(&peer_path) else {
|
|
||||||
return Err(CrossbeamChannelError::MissingStagedConnection);
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_direct_parent(endpoint.path(), &peer_path) {
|
|
||||||
self.parent = Some(ChannelConnection {
|
|
||||||
path: peer_path.clone(),
|
|
||||||
sender,
|
|
||||||
});
|
|
||||||
endpoint
|
|
||||||
.set_parent_path(Some(peer_path))
|
|
||||||
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
|
|
||||||
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_direct_child(endpoint.path(), &peer_path) {
|
|
||||||
self.children.insert(peer_path.clone(), sender);
|
|
||||||
self.sync_child_routes();
|
|
||||||
endpoint
|
|
||||||
.upsert_child_route(ChildRoute::registered(peer_path))
|
|
||||||
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
|
|
||||||
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
|
|
||||||
}
|
|
||||||
|
|
||||||
unreachable!("direct-neighbor validation returned early above")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes one active connection and returns it to the staged set.
|
|
||||||
pub fn disconnect(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
peer_path: &[String],
|
|
||||||
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
|
|
||||||
if !is_direct_parent(endpoint.path(), peer_path)
|
|
||||||
&& !is_direct_child(endpoint.path(), peer_path)
|
|
||||||
{
|
|
||||||
return Err(CrossbeamChannelError::InvalidPeerPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self
|
|
||||||
.parent
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|parent| parent.path == peer_path)
|
|
||||||
{
|
|
||||||
let Some(parent) = self.parent.take() else {
|
|
||||||
return Err(CrossbeamChannelError::MissingParentConnection);
|
|
||||||
};
|
|
||||||
self.staged.insert(parent.path, parent.sender);
|
|
||||||
endpoint
|
|
||||||
.set_parent_path(None)
|
|
||||||
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
|
|
||||||
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(sender) = self.children.remove(peer_path) else {
|
|
||||||
return Err(CrossbeamChannelError::MissingChildConnection);
|
|
||||||
};
|
|
||||||
self.staged.insert(peer_path.to_vec(), sender);
|
|
||||||
self.sync_child_routes();
|
|
||||||
endpoint.remove_child_route(peer_path);
|
|
||||||
Ok(ConnectionSnapshot::from_endpoint(endpoint))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_child_routes(&mut self) {
|
|
||||||
self.child_routes = self
|
|
||||||
.children
|
|
||||||
.keys()
|
|
||||||
.cloned()
|
|
||||||
.map(ChildRoute::registered)
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectionSnapshot {
|
|
||||||
fn from_endpoint(endpoint: &ProtocolEndpoint) -> Self {
|
|
||||||
Self {
|
|
||||||
parent: endpoint.parent_path().map(<[String]>::to_vec),
|
|
||||||
children: endpoint
|
|
||||||
.child_routes()
|
|
||||||
.iter()
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[procedures(error = CrossbeamChannelError)]
|
|
||||||
impl CrossbeamChannelLeaf {
|
|
||||||
#[call]
|
|
||||||
fn add_connection(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
request: ConnectionRequest,
|
|
||||||
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
|
|
||||||
self.connect_staged(endpoint, request.peer_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[call]
|
|
||||||
fn remove_connection(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
request: ConnectionRequest,
|
|
||||||
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
|
|
||||||
self.disconnect(endpoint, &request.peer_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[call]
|
|
||||||
fn get_connections(&mut self, endpoint: &ProtocolEndpoint) -> ConnectionSnapshot {
|
|
||||||
ConnectionSnapshot::from_endpoint(endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallLeaf for CrossbeamChannelLeaf {
|
|
||||||
type Error = CrossbeamChannelError;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RouterLeaf for CrossbeamChannelLeaf {
|
|
||||||
type RouteError = CrossbeamChannelError;
|
|
||||||
|
|
||||||
fn parent_path(&self) -> Option<&[String]> {
|
|
||||||
self.parent.as_ref().map(|parent| parent.path.as_slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn child_routes(&self) -> &[ChildRoute] {
|
|
||||||
&self.child_routes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_to_parent(
|
|
||||||
&mut self,
|
|
||||||
local_path: &[String],
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<(), Self::RouteError> {
|
|
||||||
let Some(parent) = &self.parent else {
|
|
||||||
return Err(CrossbeamChannelError::MissingParentConnection);
|
|
||||||
};
|
|
||||||
parent
|
|
||||||
.sender
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Child(local_path.to_vec()),
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.map_err(|_| CrossbeamChannelError::ChannelClosed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_to_child(
|
|
||||||
&mut self,
|
|
||||||
child_path: &[String],
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<(), Self::RouteError> {
|
|
||||||
let Some(sender) = self.children.get(child_path) else {
|
|
||||||
return Err(CrossbeamChannelError::MissingChildConnection);
|
|
||||||
};
|
|
||||||
sender
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.map_err(|_| CrossbeamChannelError::ChannelClosed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_direct_parent(local_path: &[String], peer_path: &[String]) -> bool {
|
|
||||||
local_path
|
|
||||||
.split_last()
|
|
||||||
.is_some_and(|(_, parent_path)| parent_path == peer_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_direct_child(local_path: &[String], peer_path: &[String]) -> bool {
|
|
||||||
peer_path.len() == local_path.len() + 1 && peer_path.starts_with(local_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crossbeam_channel::{Receiver, unbounded};
|
|
||||||
use unshell_protocol::decode_frame;
|
|
||||||
use unshell_protocol::tree::{
|
|
||||||
Endpoint, EndpointOutcome, LeafRuntime, decode_call_input, encode_call_reply,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChannelNode {
|
|
||||||
runtime: LeafRuntime<CrossbeamChannelLeaf>,
|
|
||||||
rx: Receiver<CrossbeamEnvelope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelNode {
|
|
||||||
fn new(path: Vec<String>) -> (Self, Sender<CrossbeamEnvelope>) {
|
|
||||||
let (tx, rx) = unbounded();
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path,
|
|
||||||
None,
|
|
||||||
Vec::new(),
|
|
||||||
vec![CrossbeamChannelLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
runtime: LeafRuntime::new(endpoint, CrossbeamChannelLeaf::default()),
|
|
||||||
rx,
|
|
||||||
},
|
|
||||||
tx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain(&mut self) -> usize {
|
|
||||||
let mut processed = 0usize;
|
|
||||||
while let Ok(envelope) = self.rx.try_recv() {
|
|
||||||
let outcome = self
|
|
||||||
.runtime
|
|
||||||
.receive_routed(&envelope.ingress, envelope.frame)
|
|
||||||
.expect("node should process routed frame");
|
|
||||||
self.runtime
|
|
||||||
.route_forwarded(outcome.forwarded)
|
|
||||||
.expect("router leaf should forward emitted frames");
|
|
||||||
processed += 1;
|
|
||||||
}
|
|
||||||
processed
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stage_connection(&mut self, peer_path: Vec<String>, sender: Sender<CrossbeamEnvelope>) {
|
|
||||||
let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect_staged(&mut self, peer_path: Vec<String>) {
|
|
||||||
let snapshot = {
|
|
||||||
let runtime = &mut self.runtime;
|
|
||||||
let mut leaf = core::mem::take(runtime.leaf_mut());
|
|
||||||
let result = leaf.connect_staged(runtime.endpoint_mut(), peer_path);
|
|
||||||
*runtime.leaf_mut() = leaf;
|
|
||||||
result
|
|
||||||
};
|
|
||||||
snapshot.expect("staged connection should activate");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crossbeam_channel_leaf_routes_calls_and_replies_across_parent_and_child_links() {
|
|
||||||
let (mut agent, root_to_agent) = ChannelNode::new(path(&["agent"]));
|
|
||||||
let (mut child, agent_to_child) = ChannelNode::new(path(&["agent", "child"]));
|
|
||||||
let (agent_to_root, root_rx) = unbounded();
|
|
||||||
|
|
||||||
let mut root = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["agent"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
agent.stage_connection(Vec::new(), agent_to_root);
|
|
||||||
agent.connect_staged(Vec::new());
|
|
||||||
|
|
||||||
child.stage_connection(path(&["agent"]), root_to_agent.clone());
|
|
||||||
child.connect_staged(path(&["agent"]));
|
|
||||||
|
|
||||||
agent.stage_connection(path(&["agent", "child"]), agent_to_child);
|
|
||||||
|
|
||||||
let hook_id = root.allocate_hook_id();
|
|
||||||
let add_connection = root
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("add_connection")
|
|
||||||
.expect("procedure should exist"),
|
|
||||||
Some(hook_id),
|
|
||||||
encode_call_reply(&ConnectionRequest {
|
|
||||||
peer_path: path(&["agent", "child"]),
|
|
||||||
})
|
|
||||||
.expect("request should encode"),
|
|
||||||
)
|
|
||||||
.expect("root should build add-connection call");
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = add_connection else {
|
|
||||||
panic!("root should forward add-connection call");
|
|
||||||
};
|
|
||||||
root_to_agent
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.expect("root should deliver frame to agent");
|
|
||||||
|
|
||||||
for _ in 0..8 {
|
|
||||||
let mut progress = 0usize;
|
|
||||||
progress += agent.drain();
|
|
||||||
progress += child.drain();
|
|
||||||
while let Ok(envelope) = root_rx.try_recv() {
|
|
||||||
let outcome = root
|
|
||||||
.receive(&envelope.ingress, envelope.frame)
|
|
||||||
.expect("root should accept reply frame");
|
|
||||||
if let EndpointOutcome::Local(local) = outcome {
|
|
||||||
match local {
|
|
||||||
unshell_protocol::tree::LocalEvent::Data { .. }
|
|
||||||
| unshell_protocol::tree::LocalEvent::Fault { .. } => {}
|
|
||||||
unshell_protocol::tree::LocalEvent::Call { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progress += 1;
|
|
||||||
}
|
|
||||||
if progress == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(agent.runtime.endpoint().child_routes().len(), 1);
|
|
||||||
|
|
||||||
let query_hook = root.allocate_hook_id();
|
|
||||||
let query = root
|
|
||||||
.send_call(
|
|
||||||
path(&["agent", "child"]),
|
|
||||||
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("get_connections")
|
|
||||||
.expect("procedure should exist"),
|
|
||||||
Some(query_hook),
|
|
||||||
encode_call_reply(&()).expect("unit request should encode"),
|
|
||||||
)
|
|
||||||
.expect("root should build query call");
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = query else {
|
|
||||||
panic!("root should forward query call");
|
|
||||||
};
|
|
||||||
root_to_agent
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.expect("root should deliver query to agent");
|
|
||||||
|
|
||||||
let mut reply = None;
|
|
||||||
for _ in 0..12 {
|
|
||||||
let mut progress = 0usize;
|
|
||||||
progress += agent.drain();
|
|
||||||
progress += child.drain();
|
|
||||||
while let Ok(envelope) = root_rx.try_recv() {
|
|
||||||
let outcome = root
|
|
||||||
.receive(&envelope.ingress, envelope.frame)
|
|
||||||
.expect("root should accept routed reply");
|
|
||||||
if let EndpointOutcome::Local(unshell_protocol::tree::LocalEvent::Data {
|
|
||||||
message,
|
|
||||||
..
|
|
||||||
}) = outcome
|
|
||||||
{
|
|
||||||
reply = Some(
|
|
||||||
decode_call_input::<ConnectionSnapshot>(message.data.as_slice())
|
|
||||||
.expect("reply payload should decode"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
progress += 1;
|
|
||||||
}
|
|
||||||
if reply.is_some() || progress == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let reply = reply.expect("root should receive child connection snapshot");
|
|
||||||
assert_eq!(reply.parent, Some(path(&["agent"])));
|
|
||||||
assert!(reply.children.is_empty());
|
|
||||||
|
|
||||||
let remove_hook = root.allocate_hook_id();
|
|
||||||
let remove = root
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("remove_connection")
|
|
||||||
.expect("procedure should exist"),
|
|
||||||
Some(remove_hook),
|
|
||||||
encode_call_reply(&ConnectionRequest {
|
|
||||||
peer_path: path(&["agent", "child"]),
|
|
||||||
})
|
|
||||||
.expect("request should encode"),
|
|
||||||
)
|
|
||||||
.expect("root should build remove-connection call");
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = remove else {
|
|
||||||
panic!("root should forward remove-connection call");
|
|
||||||
};
|
|
||||||
root_to_agent
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.expect("root should deliver removal call to agent");
|
|
||||||
|
|
||||||
for _ in 0..8 {
|
|
||||||
let mut progress = 0usize;
|
|
||||||
progress += agent.drain();
|
|
||||||
progress += child.drain();
|
|
||||||
while let Ok(envelope) = root_rx.try_recv() {
|
|
||||||
let _ = root
|
|
||||||
.receive(&envelope.ingress, envelope.frame)
|
|
||||||
.expect("root should process removal reply");
|
|
||||||
progress += 1;
|
|
||||||
}
|
|
||||||
if progress == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(agent.runtime.endpoint().child_routes().is_empty());
|
|
||||||
let final_hook = root.allocate_hook_id();
|
|
||||||
let dropped = root
|
|
||||||
.send_call(
|
|
||||||
path(&["agent", "child"]),
|
|
||||||
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
|
|
||||||
CrossbeamChannelLeaf::protocol_procedure_id("get_connections")
|
|
||||||
.expect("procedure should exist"),
|
|
||||||
Some(final_hook),
|
|
||||||
encode_call_reply(&()).expect("unit request should encode"),
|
|
||||||
)
|
|
||||||
.expect("query call should encode after removal");
|
|
||||||
assert!(matches!(dropped, EndpointOutcome::Forward { .. }));
|
|
||||||
|
|
||||||
if let EndpointOutcome::Forward { frame, .. } = dropped {
|
|
||||||
root_to_agent
|
|
||||||
.send(CrossbeamEnvelope {
|
|
||||||
ingress: Ingress::Parent,
|
|
||||||
frame,
|
|
||||||
})
|
|
||||||
.expect("root should still reach the agent");
|
|
||||||
}
|
|
||||||
let mut saw_reply = false;
|
|
||||||
for _ in 0..8 {
|
|
||||||
let mut progress = 0usize;
|
|
||||||
progress += agent.drain();
|
|
||||||
progress += child.drain();
|
|
||||||
while let Ok(envelope) = root_rx.try_recv() {
|
|
||||||
progress += 1;
|
|
||||||
if let EndpointOutcome::Local(unshell_protocol::tree::LocalEvent::Data {
|
|
||||||
message,
|
|
||||||
..
|
|
||||||
}) = root
|
|
||||||
.receive(&envelope.ingress, envelope.frame)
|
|
||||||
.expect("root should process any late reply")
|
|
||||||
{
|
|
||||||
let _ = decode_frame(message.data.as_slice());
|
|
||||||
saw_reply = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if progress == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!saw_reply,
|
|
||||||
"removed child route should stop forwarded replies"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_add_connection_keeps_staged_sender_available_for_retry() {
|
|
||||||
let (tx, _rx) = unbounded();
|
|
||||||
let mut leaf = CrossbeamChannelLeaf::default();
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(path(&["agent"]), None, Vec::new(), Vec::new());
|
|
||||||
leaf.stage_connection(path(&["elsewhere"]), tx);
|
|
||||||
|
|
||||||
let error = leaf
|
|
||||||
.connect_staged(&mut endpoint, path(&["elsewhere"]))
|
|
||||||
.expect_err("non-neighbor path should fail");
|
|
||||||
assert_eq!(error, CrossbeamChannelError::InvalidPeerPath);
|
|
||||||
assert!(leaf.staged.contains_key(&path(&["elsewhere"])));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_remove_connection_reports_invalid_peer_path() {
|
|
||||||
let mut leaf = CrossbeamChannelLeaf::default();
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(path(&["agent"]), None, Vec::new(), Vec::new());
|
|
||||||
|
|
||||||
let error = leaf
|
|
||||||
.disconnect(&mut endpoint, &path(&["not", "a", "neighbor"]))
|
|
||||||
.expect_err("non-neighbor removal should fail");
|
|
||||||
assert_eq!(error, CrossbeamChannelError::InvalidPeerPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
//! Application-layer leaves and user-facing surfaces built on top of the UnShell
|
|
||||||
//! protocol runtime.
|
|
||||||
//!
|
|
||||||
//! Each leaf module always exports its shared protocol-facing types. Role-specific
|
|
||||||
//! implementations are selected with the crate-wide `leaf_endpoint` and `leaf_tui`
|
|
||||||
//! features, and can optionally be re-exported behind one stable alias.
|
|
||||||
|
|
||||||
#[allow(unused_extern_crates)]
|
|
||||||
extern crate self as unshell;
|
|
||||||
|
|
||||||
pub extern crate alloc;
|
|
||||||
|
|
||||||
use unshell_protocol::DataMessage;
|
|
||||||
|
|
||||||
pub use unshell_macros::{Procedure, leaf, procedures};
|
|
||||||
pub use unshell_protocol as protocol;
|
|
||||||
|
|
||||||
/// Re-exports one role-specific type behind a stable public alias.
|
|
||||||
///
|
|
||||||
/// What it is: a small macro that binds one public type alias to either an
|
|
||||||
/// endpoint-facing leaf host or a TUI-facing leaf host based on active features.
|
|
||||||
///
|
|
||||||
/// Why it exists: downstream code should be able to import one stable name such as
|
|
||||||
/// `RemoteShell` without caring which concrete role implementation was compiled for
|
|
||||||
/// the current binary.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::role_leaf;
|
|
||||||
/// mod endpoint { pub struct DemoEndpoint; }
|
|
||||||
/// mod tui { pub struct DemoTui; }
|
|
||||||
/// role_leaf! {
|
|
||||||
/// pub type DemoLeaf {
|
|
||||||
/// endpoint => endpoint::DemoEndpoint,
|
|
||||||
/// tui => tui::DemoTui,
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// # #[cfg(feature = "leaf_endpoint")]
|
|
||||||
/// # let _ = core::marker::PhantomData::<DemoLeaf>;
|
|
||||||
/// ```
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! role_leaf {
|
|
||||||
(
|
|
||||||
$(#[$meta:meta])*
|
|
||||||
$vis:vis type $alias:ident {
|
|
||||||
endpoint => $endpoint:path,
|
|
||||||
tui => $tui:path $(,)?
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
#[cfg(all(feature = "leaf_endpoint", feature = "leaf_tui"))]
|
|
||||||
compile_error!(concat!(
|
|
||||||
"`",
|
|
||||||
stringify!($alias),
|
|
||||||
"` can only alias one concrete role at a time; enable either `leaf_endpoint` or `leaf_tui`, not both"
|
|
||||||
));
|
|
||||||
|
|
||||||
#[cfg(feature = "leaf_endpoint")]
|
|
||||||
$(#[$meta])*
|
|
||||||
$vis type $alias = $endpoint;
|
|
||||||
|
|
||||||
#[cfg(all(not(feature = "leaf_endpoint"), feature = "leaf_tui"))]
|
|
||||||
$(#[$meta])*
|
|
||||||
$vis type $alias = $tui;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal leaf-specific TUI contract.
|
|
||||||
///
|
|
||||||
/// What it is: the smallest public trait a leaf-specific user interface needs in
|
|
||||||
/// order to consume protocol `DataMessage` values and render a textual frame.
|
|
||||||
///
|
|
||||||
/// Why it exists: leaf UIs should remain transport-agnostic and renderer-agnostic,
|
|
||||||
/// so callers can experiment with CLIs and TUIs without coupling the core leaf API
|
|
||||||
/// to any one terminal framework.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::{LeafTui, TuiError};
|
|
||||||
/// use unshell_leaves::protocol::DataMessage;
|
|
||||||
/// struct DemoTui;
|
|
||||||
/// impl LeafTui for DemoTui {
|
|
||||||
/// fn leaf_name(&self) -> String { "org.example.v1.demo".into() }
|
|
||||||
/// fn handle_data(&mut self, _message: &DataMessage) -> Result<(), TuiError> { Ok(()) }
|
|
||||||
/// fn render(&self) -> String { String::from("demo") }
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(DemoTui.render(), "demo");
|
|
||||||
/// ```
|
|
||||||
pub trait LeafTui {
|
|
||||||
/// Returns the canonical protocol leaf name this UI understands.
|
|
||||||
fn leaf_name(&self) -> String;
|
|
||||||
|
|
||||||
/// Applies one inbound hook payload to the local UI state.
|
|
||||||
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError>;
|
|
||||||
|
|
||||||
/// Produces the current textual frame for the leaf.
|
|
||||||
fn render(&self) -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lightweight error used by the leaf TUI surface.
|
|
||||||
///
|
|
||||||
/// What it is: a small owned-string error for UI adapters built on [`LeafTui`].
|
|
||||||
///
|
|
||||||
/// Why it exists: the TUI surface should not force downstream UIs into a heavier
|
|
||||||
/// error dependency just to report leaf-local rendering or decoding failures.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell_leaves::TuiError;
|
|
||||||
/// let error = TuiError::new("invalid frame");
|
|
||||||
/// assert_eq!(error.to_string(), "invalid frame");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TuiError {
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiError {
|
|
||||||
/// Creates one UI-surface error from owned text.
|
|
||||||
pub fn new(message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::fmt::Display for TuiError {
|
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
||||||
f.write_str(&self.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::error::Error for TuiError {}
|
|
||||||
|
|
||||||
pub mod crossbeam_channel;
|
|
||||||
pub mod remote_shell;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
//! PTY-backed endpoint implementation for the remote shell leaf.
|
|
||||||
|
|
||||||
mod errors;
|
|
||||||
mod session;
|
|
||||||
mod transport;
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
|
|
||||||
|
|
||||||
pub use errors::ShellLeafError;
|
|
||||||
pub use session::Open;
|
|
||||||
pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames};
|
|
||||||
|
|
||||||
use super::OpenRequest;
|
|
||||||
|
|
||||||
/// Leaf state for the remote shell endpoint runtime.
|
|
||||||
///
|
|
||||||
/// The endpoint keeps each live shell session in an explicit map keyed by the
|
|
||||||
/// caller-owned hook identity. That makes ownership and cleanup of hook-backed
|
|
||||||
/// shell processes easy to inspect during debugging.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct RemoteShell {
|
|
||||||
sessions: BTreeMap<HookKey, Open>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureStore<Open> for RemoteShell {
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> {
|
|
||||||
&mut self.sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Procedure<RemoteShell> for Open {
|
|
||||||
type Error = ShellLeafError;
|
|
||||||
type Input = OpenRequest;
|
|
||||||
|
|
||||||
fn open(_leaf: &mut RemoteShell, call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
|
||||||
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
|
|
||||||
Open::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_data(
|
|
||||||
_leaf: &mut RemoteShell,
|
|
||||||
session: &mut Self,
|
|
||||||
data: unshell::protocol::tree::IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
session.on_data(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_fault(
|
|
||||||
_leaf: &mut RemoteShell,
|
|
||||||
_session: &mut Self,
|
|
||||||
_fault: unshell::protocol::tree::IncomingFault,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll(_leaf: &mut RemoteShell, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
session.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(_leaf: &mut RemoteShell, mut session: Self) -> Result<(), Self::Error> {
|
|
||||||
session.terminate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use std::fmt;
|
|
||||||
use std::io;
|
|
||||||
|
|
||||||
/// Error produced by the remote shell endpoint implementation.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ShellLeafError {
|
|
||||||
/// Underlying PTY or I/O failure.
|
|
||||||
Io(io::Error),
|
|
||||||
/// Shell open requires a response hook so the session can stream bytes back.
|
|
||||||
MissingHook,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ShellLeafError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Io(error) => write!(f, "{error}"),
|
|
||||||
Self::MissingHook => f.write_str("shell open requires a response hook"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for ShellLeafError {}
|
|
||||||
|
|
||||||
impl From<io::Error> for ShellLeafError {
|
|
||||||
fn from(value: io::Error) -> Self {
|
|
||||||
Self::Io(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
//! Per-hook remote shell session lifecycle.
|
|
||||||
//!
|
|
||||||
//! A session opens one PTY-backed shell process and translates protocol hook
|
|
||||||
//! traffic into stdin writes and stdout or stderr chunks. Close is intentionally
|
|
||||||
//! two-sided: the peer signals input completion with `end_hook`, while the local
|
|
||||||
//! side closes only after the child exits and the PTY reader drains.
|
|
||||||
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
|
|
||||||
use unshell::Procedure;
|
|
||||||
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
|
|
||||||
|
|
||||||
use super::RemoteShell;
|
|
||||||
use super::errors::ShellLeafError;
|
|
||||||
|
|
||||||
/// Per-hook shell session created by the `open` procedure.
|
|
||||||
///
|
|
||||||
/// The procedure type is also the stored session type so the mapping between
|
|
||||||
/// one opening procedure and one live hook remains direct and visible.
|
|
||||||
#[derive(Procedure)]
|
|
||||||
#[procedure(leaf = RemoteShell, name = "open")]
|
|
||||||
pub struct Open {
|
|
||||||
/// Spawned PTY child process.
|
|
||||||
pub(super) child: Box<dyn portable_pty::Child + Send>,
|
|
||||||
/// Process-group leader used for Unix hangup and kill signaling.
|
|
||||||
process_group_leader: Option<u32>,
|
|
||||||
/// Buffered stdin bridge into the shell process.
|
|
||||||
stdin_tx: Option<SyncSender<Vec<u8>>>,
|
|
||||||
/// Buffered output stream read from the PTY.
|
|
||||||
output_rx: Receiver<OutputEvent>,
|
|
||||||
/// Hook return path for packets emitted by this session.
|
|
||||||
return_path: Vec<String>,
|
|
||||||
/// Hook identifier allocated by the caller.
|
|
||||||
hook_id: u64,
|
|
||||||
/// Procedure id bound to this shell hook.
|
|
||||||
procedure_id: String,
|
|
||||||
/// Whether the PTY reader has closed and drained.
|
|
||||||
output_closed: bool,
|
|
||||||
/// Observed child exit status, once known.
|
|
||||||
pub(super) exit_status: Option<ExitStatus>,
|
|
||||||
/// Whether this session already emitted its terminal local packet.
|
|
||||||
pub(super) local_end_sent: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One event forwarded from the PTY reader thread.
|
|
||||||
enum OutputEvent {
|
|
||||||
Chunk(Vec<u8>),
|
|
||||||
ReaderClosed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Open {
|
|
||||||
pub(super) fn spawn(
|
|
||||||
return_path: Vec<String>,
|
|
||||||
hook_id: u64,
|
|
||||||
procedure_id: String,
|
|
||||||
) -> Result<Self, ShellLeafError> {
|
|
||||||
let command = build_shell_command();
|
|
||||||
let pty_system = native_pty_system();
|
|
||||||
let pair = pty_system
|
|
||||||
.openpty(PtySize {
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
pixel_width: 0,
|
|
||||||
pixel_height: 0,
|
|
||||||
})
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
|
|
||||||
let child = pair
|
|
||||||
.slave
|
|
||||||
.spawn_command(command)
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
let process_group_leader = child.process_id();
|
|
||||||
let stdin = pair
|
|
||||||
.master
|
|
||||||
.take_writer()
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
let stdout = pair
|
|
||||||
.master
|
|
||||||
.try_clone_reader()
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
|
|
||||||
let (stdin_tx, rx) = spawn_io_threads(stdin, stdout);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
child,
|
|
||||||
process_group_leader,
|
|
||||||
stdin_tx: Some(stdin_tx),
|
|
||||||
output_rx: rx,
|
|
||||||
return_path,
|
|
||||||
hook_id,
|
|
||||||
procedure_id,
|
|
||||||
output_closed: false,
|
|
||||||
exit_status: None,
|
|
||||||
local_end_sent: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds one outgoing hook packet owned by this session.
|
|
||||||
pub(super) fn packet(&self, data: Vec<u8>, end_hook: bool) -> OutgoingData {
|
|
||||||
OutgoingData {
|
|
||||||
dst_path: self.return_path.clone(),
|
|
||||||
hook_id: self.hook_id,
|
|
||||||
procedure_id: self.procedure_id.clone(),
|
|
||||||
data,
|
|
||||||
end_hook,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forces the underlying shell process to stop and records its exit status.
|
|
||||||
pub(super) fn terminate(&mut self) -> Result<(), ShellLeafError> {
|
|
||||||
self.stdin_tx.take();
|
|
||||||
match self.child.try_wait()? {
|
|
||||||
Some(status) => {
|
|
||||||
self.exit_status = Some(status);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.signal_process_group("-KILL");
|
|
||||||
self.child
|
|
||||||
.kill()
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
self.exit_status = Some(
|
|
||||||
self.child
|
|
||||||
.wait()
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drains any currently buffered PTY output into protocol packets.
|
|
||||||
pub(super) fn drain_output(&mut self, outgoing: &mut Vec<OutgoingData>) {
|
|
||||||
loop {
|
|
||||||
match self.output_rx.try_recv() {
|
|
||||||
Ok(OutputEvent::Chunk(bytes)) => outgoing.push(self.packet(bytes, false)),
|
|
||||||
Ok(OutputEvent::ReaderClosed) => self.output_closed = true,
|
|
||||||
Err(TryRecvError::Empty) => break,
|
|
||||||
Err(TryRecvError::Disconnected) => {
|
|
||||||
self.output_closed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies one inbound hook payload to the shell process.
|
|
||||||
pub(super) fn on_data(
|
|
||||||
&mut self,
|
|
||||||
data: IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, ShellLeafError> {
|
|
||||||
if !data.message.data.is_empty() {
|
|
||||||
let Some(stdin_tx) = self.stdin_tx.as_ref() else {
|
|
||||||
return Ok(ProcedureEffect::default());
|
|
||||||
};
|
|
||||||
stdin_tx.try_send(data.message.data).map_err(|_| {
|
|
||||||
io::Error::new(io::ErrorKind::WouldBlock, "shell stdin channel full")
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !data.message.end_hook {
|
|
||||||
return Ok(ProcedureEffect::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peer end means no more stdin from the caller. Keep the process alive so
|
|
||||||
// buffered PTY output can drain through the normal poll path.
|
|
||||||
self.stdin_tx.take();
|
|
||||||
self.signal_process_group("-HUP");
|
|
||||||
Ok(ProcedureEffect::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls the shell for locally-generated output.
|
|
||||||
pub(super) fn poll(&mut self) -> Result<ProcedureEffect, ShellLeafError> {
|
|
||||||
let mut outgoing = Vec::new();
|
|
||||||
self.drain_output(&mut outgoing);
|
|
||||||
|
|
||||||
if self.local_end_sent {
|
|
||||||
return Ok(ProcedureEffect::outgoing(outgoing));
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.exit_status.is_none() {
|
|
||||||
self.exit_status = self
|
|
||||||
.child
|
|
||||||
.try_wait()
|
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.exit_status.is_some() && !self.output_closed {
|
|
||||||
self.signal_process_group("-KILL");
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.exit_status.is_some() && self.output_closed {
|
|
||||||
outgoing.push(self.packet(Vec::new(), true));
|
|
||||||
self.local_end_sent = true;
|
|
||||||
return Ok(ProcedureEffect::close(outgoing));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ProcedureEffect::outgoing(outgoing))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signal_process_group(&self, signal: &str) {
|
|
||||||
#[cfg(unix)]
|
|
||||||
if let Some(process_group_leader) = self.process_group_leader {
|
|
||||||
let _ = Command::new("kill")
|
|
||||||
.arg(signal)
|
|
||||||
.arg(format!("-{}", process_group_leader))
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Open {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.terminate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_pipe_writer(mut stdin: Box<dyn Write + Send>, rx: Receiver<Vec<u8>>) {
|
|
||||||
thread::spawn(move || {
|
|
||||||
for bytes in rx {
|
|
||||||
if stdin.write_all(&bytes).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if stdin.flush().is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_shell_command() -> CommandBuilder {
|
|
||||||
if cfg!(windows) {
|
|
||||||
let mut command = CommandBuilder::new("cmd.exe");
|
|
||||||
command.arg("/Q");
|
|
||||||
command
|
|
||||||
} else {
|
|
||||||
let mut command = CommandBuilder::new("/bin/sh");
|
|
||||||
command.arg("-i");
|
|
||||||
command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_io_threads(
|
|
||||||
stdin: Box<dyn Write + Send>,
|
|
||||||
stdout: Box<dyn Read + Send>,
|
|
||||||
) -> (SyncSender<Vec<u8>>, Receiver<OutputEvent>) {
|
|
||||||
let (stdin_tx, stdin_rx) = mpsc::sync_channel(64);
|
|
||||||
let (tx, rx) = mpsc::sync_channel(64);
|
|
||||||
spawn_pipe_writer(stdin, stdin_rx);
|
|
||||||
spawn_pipe_reader(stdout, tx);
|
|
||||||
(stdin_tx, rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_pipe_reader<R>(mut reader: R, tx: mpsc::SyncSender<OutputEvent>)
|
|
||||||
where
|
|
||||||
R: Read + Send + 'static,
|
|
||||||
{
|
|
||||||
thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
let mut buffer = [0u8; 1024];
|
|
||||||
match reader.read(&mut buffer) {
|
|
||||||
Ok(0) => {
|
|
||||||
let _ = tx.send(OutputEvent::ReaderClosed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(read_len) => {
|
|
||||||
if tx
|
|
||||||
.send(OutputEvent::Chunk(buffer[..read_len].to_vec()))
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(OutputEvent::Chunk(
|
|
||||||
format!("shell pipe read error: {error}\n").into_bytes(),
|
|
||||||
));
|
|
||||||
let _ = tx.send(OutputEvent::ReaderClosed);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
use std::io::{self, ErrorKind, Read, Write};
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::sync::mpsc::{self, Receiver};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use unshell::protocol::FrameBytes;
|
|
||||||
use unshell::protocol::tree::EndpointOutcome;
|
|
||||||
|
|
||||||
/// TCP listen address used by the remote shell examples.
|
|
||||||
pub const LISTEN_ADDR: &str = "127.0.0.1:4444";
|
|
||||||
const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
|
||||||
|
|
||||||
/// Writes the forwarded frame produced by one endpoint outcome.
|
|
||||||
pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Result<()> {
|
|
||||||
match outcome {
|
|
||||||
EndpointOutcome::Forward { frame, .. } => write_frames(stream, &[frame]),
|
|
||||||
EndpointOutcome::Local(_) | EndpointOutcome::Dropped => write_frames(stream, &[]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes one or more framed packets onto the example TCP stream.
|
|
||||||
pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result<()> {
|
|
||||||
for frame in frames {
|
|
||||||
let frame_len = u32::try_from(frame.len()).map_err(|_| {
|
|
||||||
io::Error::new(ErrorKind::InvalidData, "frame exceeds u32 transport size")
|
|
||||||
})?;
|
|
||||||
stream.write_all(&frame_len.to_be_bytes())?;
|
|
||||||
stream.write_all(frame)?;
|
|
||||||
}
|
|
||||||
stream.flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the example frame reader that lifts prefixed frames off the TCP stream.
|
|
||||||
pub fn spawn_frame_reader(mut stream: TcpStream) -> Receiver<io::Result<FrameBytes>> {
|
|
||||||
let (tx, rx) = mpsc::sync_channel(64);
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
match read_frame(&mut stream) {
|
|
||||||
Ok(Some(frame)) => {
|
|
||||||
if tx.send(Ok(frame)).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(Err(error));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rx
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_frame(stream: &mut TcpStream) -> io::Result<Option<FrameBytes>> {
|
|
||||||
let Some(len_bytes) = read_prefix(stream)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let frame_len = u32::from_be_bytes(len_bytes) as usize;
|
|
||||||
if frame_len > MAX_FRAME_BYTES {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
"frame exceeds remote shell example transport limit",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut bytes = vec![0u8; frame_len];
|
|
||||||
stream.read_exact(&mut bytes)?;
|
|
||||||
|
|
||||||
let mut frame = FrameBytes::with_capacity(bytes.len());
|
|
||||||
frame.extend_from_slice(&bytes);
|
|
||||||
Ok(Some(frame))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_prefix(stream: &mut TcpStream) -> io::Result<Option<[u8; 4]>> {
|
|
||||||
let mut len_bytes = [0u8; 4];
|
|
||||||
let mut filled = 0usize;
|
|
||||||
|
|
||||||
while filled < len_bytes.len() {
|
|
||||||
match stream.read(&mut len_bytes[filled..]) {
|
|
||||||
Ok(0) if filled == 0 => return Ok(None),
|
|
||||||
Ok(0) => return Err(io::Error::from(ErrorKind::UnexpectedEof)),
|
|
||||||
Ok(read_len) => filled += read_len,
|
|
||||||
Err(error) if error.kind() == ErrorKind::Interrupted => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(len_bytes))
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
//! Remote shell leaf and its user-facing surfaces.
|
|
||||||
//!
|
|
||||||
//! The module always exports the protocol contract for the leaf together with the
|
|
||||||
//! endpoint and TUI host implementations.
|
|
||||||
|
|
||||||
use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
use unshell_macros::leaf;
|
|
||||||
|
|
||||||
pub mod endpoint;
|
|
||||||
pub mod tui;
|
|
||||||
|
|
||||||
/// Open-request payload for the remote shell leaf.
|
|
||||||
///
|
|
||||||
/// The shell currently needs no structured arguments, but a named payload type is
|
|
||||||
/// easier for downstream code to discover than a bare `()`.
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
|
||||||
pub struct OpenRequest;
|
|
||||||
|
|
||||||
#[leaf(
|
|
||||||
name = "remote_shell",
|
|
||||||
procedures = [Open],
|
|
||||||
endpoint = endpoint,
|
|
||||||
tui = tui,
|
|
||||||
)]
|
|
||||||
/// Shared compile-time declaration for the `remote_shell` leaf surface.
|
|
||||||
pub struct RemoteShell;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
//! Placeholder client-side TUI surface for the remote shell leaf.
|
|
||||||
//!
|
|
||||||
//! The first application-layer consumer will be a CLI and later a full GUI. This
|
|
||||||
//! stub keeps the leaf-specific interpretation point in place without forcing a
|
|
||||||
//! rendering-library decision yet.
|
|
||||||
|
|
||||||
use std::string::String;
|
|
||||||
use std::vec::Vec;
|
|
||||||
|
|
||||||
use unshell::protocol::DataMessage;
|
|
||||||
use unshell_macros::Procedure;
|
|
||||||
|
|
||||||
use crate::{LeafTui, TuiError};
|
|
||||||
|
|
||||||
/// Stub TUI surface for the remote shell leaf.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct RemoteShell {
|
|
||||||
transcript: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoteShell {
|
|
||||||
/// Returns a short explanation of the current stub status.
|
|
||||||
pub fn status_line(&self) -> &'static str {
|
|
||||||
"remote shell TUI stub: rendering is placeholder-only for now"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LeafTui for RemoteShell {
|
|
||||||
fn leaf_name(&self) -> String {
|
|
||||||
Self::protocol_leaf_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError> {
|
|
||||||
self.transcript.extend_from_slice(&message.data);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self) -> String {
|
|
||||||
let body = String::from_utf8_lossy(&self.transcript);
|
|
||||||
format!("{}\n\n{}", self.status_line(), body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TUI-side placeholder procedure symbol for the shared `remote_shell` leaf
|
|
||||||
/// declaration.
|
|
||||||
#[derive(Procedure)]
|
|
||||||
#[procedure(leaf = RemoteShell, name = "open")]
|
|
||||||
pub struct Open {}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# UnShell Macros
|
|
||||||
|
|
||||||
This crate owns the compile-time declaration layer for UnShell application-facing
|
|
||||||
leaves.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
The protocol crate intentionally stays generic: it knows how to route packets,
|
|
||||||
validate framing, and deliver local events, but it should not need handwritten
|
|
||||||
registration code for every leaf.
|
|
||||||
|
|
||||||
The macro layer exists to move as much of that registration work as possible to
|
|
||||||
compile time.
|
|
||||||
|
|
||||||
In practical terms, the macro system is responsible for:
|
|
||||||
|
|
||||||
- deriving canonical procedure identifiers
|
|
||||||
- generating compile-time procedure inventories for leaves
|
|
||||||
- binding one leaf declaration to separate endpoint and TUI host modules without
|
|
||||||
repeating the metadata on each host
|
|
||||||
- generating dispatch glue for simple call-driven leaves
|
|
||||||
|
|
||||||
## Model
|
|
||||||
|
|
||||||
There are three layers in the intended design.
|
|
||||||
|
|
||||||
### 1. Leaf declaration
|
|
||||||
|
|
||||||
One declaration is the source of truth for one protocol leaf.
|
|
||||||
|
|
||||||
The declaration answers:
|
|
||||||
|
|
||||||
- what is this leaf called on the wire?
|
|
||||||
- which procedure suffixes belong to it?
|
|
||||||
- which host modules implement its endpoint and TUI roles?
|
|
||||||
|
|
||||||
The goal is that this information is written once and reused everywhere.
|
|
||||||
|
|
||||||
### 2. Host structs
|
|
||||||
|
|
||||||
One leaf can have multiple host structs with different responsibilities.
|
|
||||||
|
|
||||||
- the endpoint host owns runtime state and protocol-side behavior
|
|
||||||
- the TUI host owns user-interface state and interpretation behavior
|
|
||||||
|
|
||||||
Those hosts should not each have to repeat the leaf name or procedure inventory.
|
|
||||||
They bind to the declaration instead.
|
|
||||||
|
|
||||||
The current convention is module-based. A declaration such as:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[leaf(
|
|
||||||
name = "remote_shell",
|
|
||||||
procedures = [Open],
|
|
||||||
endpoint = endpoint,
|
|
||||||
tui = tui,
|
|
||||||
)]
|
|
||||||
pub struct RemoteShell;
|
|
||||||
```
|
|
||||||
|
|
||||||
means:
|
|
||||||
|
|
||||||
- the endpoint host type is inferred as `endpoint::RemoteShell`
|
|
||||||
- the TUI host type is inferred as `tui::RemoteShell`
|
|
||||||
- type-based procedure metadata is resolved from the endpoint module as
|
|
||||||
`endpoint::Open`
|
|
||||||
|
|
||||||
This convention removes repeated host type paths from the declaration while still
|
|
||||||
keeping the generated code deterministic and inspectable.
|
|
||||||
|
|
||||||
### 3. Procedure and method metadata
|
|
||||||
|
|
||||||
Procedures and future typed remote methods need stable canonical identifiers.
|
|
||||||
|
|
||||||
The macro layer generates those identifiers from the leaf declaration and the
|
|
||||||
local suffix for each procedure or method. That lets the runtime consume a
|
|
||||||
compile-time inventory instead of handwritten lists.
|
|
||||||
|
|
||||||
## Current direction
|
|
||||||
|
|
||||||
The public declaration model is now centered on `#[leaf(...)]`.
|
|
||||||
|
|
||||||
- `#[leaf(...)]` declares the canonical protocol surface once
|
|
||||||
- `#[derive(Procedure)]` derives stateful procedure metadata
|
|
||||||
- `#[procedures]` derives one-shot call dispatch for simple leaves
|
|
||||||
|
|
||||||
The next evolution from here is typed remote-method metadata on top of the same
|
|
||||||
declaration model.
|
|
||||||
|
|
||||||
## Design constraints
|
|
||||||
|
|
||||||
The system is optimized for a few constraints that matter to this repository.
|
|
||||||
|
|
||||||
- compile-time declaration should replace handwritten runtime registration where
|
|
||||||
possible
|
|
||||||
- protocol-visible names should remain deterministic and canonical
|
|
||||||
- generated code should stay explicit enough to debug
|
|
||||||
- endpoint and TUI roles should share metadata but not be forced into the same
|
|
||||||
runtime trait when their behavior differs
|
|
||||||
- host inference should stay convention-based instead of discovery-based so a
|
|
||||||
declaration can be understood from its source without macro expansion tools
|
|
||||||
- migration should be low-breakage for the existing examples and tests
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
This crate does not own transport, connection management, or packet execution.
|
|
||||||
Those remain in `unshell-protocol` and higher application layers.
|
|
||||||
|
|
||||||
The macro crate should generate metadata and glue, not hide the runtime model.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "unshell-macros"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
description = "Proc macros for unshell leaf declarations"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
proc-macro = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
syn = { workspace = true, features = ["full"] }
|
|
||||||
quote = { workspace = true }
|
|
||||||
proc-macro2 = { workspace = true }
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
use proc_macro2::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{
|
|
||||||
Error, ItemStruct, LitStr, Path, Result, Token,
|
|
||||||
parse::{Parse, ParseStream},
|
|
||||||
punctuated::Punctuated,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::utils::option_litstr_tokens;
|
|
||||||
|
|
||||||
pub(crate) struct LeafDeclarationAttributes {
|
|
||||||
name: Option<LitStr>,
|
|
||||||
id: Option<LitStr>,
|
|
||||||
org: Option<LitStr>,
|
|
||||||
product: Option<LitStr>,
|
|
||||||
version: Option<LitStr>,
|
|
||||||
procedures: Vec<ProcedureRef>,
|
|
||||||
host_bindings: Vec<HostBinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parse for LeafDeclarationAttributes {
|
|
||||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
|
||||||
let assignments = Punctuated::<LeafAssignment, Token![,]>::parse_terminated(input)?;
|
|
||||||
let mut parsed = Self {
|
|
||||||
name: None,
|
|
||||||
id: None,
|
|
||||||
org: None,
|
|
||||||
product: None,
|
|
||||||
version: None,
|
|
||||||
procedures: Vec::new(),
|
|
||||||
host_bindings: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for assignment in assignments {
|
|
||||||
match assignment {
|
|
||||||
LeafAssignment::Name(value) => set_once(&mut parsed.name, value, "leaf name")?,
|
|
||||||
LeafAssignment::Id(value) => set_once(&mut parsed.id, value, "leaf id")?,
|
|
||||||
LeafAssignment::Org(value) => set_once(&mut parsed.org, value, "leaf org")?,
|
|
||||||
LeafAssignment::Product(value) => {
|
|
||||||
set_once(&mut parsed.product, value, "leaf product")?
|
|
||||||
}
|
|
||||||
LeafAssignment::Version(value) => {
|
|
||||||
set_once(&mut parsed.version, value, "leaf version")?
|
|
||||||
}
|
|
||||||
LeafAssignment::Procedures(values) => {
|
|
||||||
if !parsed.procedures.is_empty() {
|
|
||||||
return Err(Error::new(input.span(), "duplicate procedures list"));
|
|
||||||
}
|
|
||||||
parsed.procedures = values;
|
|
||||||
}
|
|
||||||
LeafAssignment::HostBinding(binding) => parsed.host_bindings.push(binding),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.name.is_none() && parsed.id.is_none() {
|
|
||||||
return Err(Error::new(
|
|
||||||
input.span(),
|
|
||||||
"#[leaf(...)] requires either `name = \"...\"` or `id = \"...\"`",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if parsed.host_bindings.is_empty() {
|
|
||||||
return Err(Error::new(
|
|
||||||
input.span(),
|
|
||||||
"#[leaf(...)] requires at least one host binding",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LeafAssignment {
|
|
||||||
Name(LitStr),
|
|
||||||
Id(LitStr),
|
|
||||||
Org(LitStr),
|
|
||||||
Product(LitStr),
|
|
||||||
Version(LitStr),
|
|
||||||
Procedures(Vec<ProcedureRef>),
|
|
||||||
HostBinding(HostBinding),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HostBinding {
|
|
||||||
module_path: Option<Path>,
|
|
||||||
host_path: Option<Path>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ProcedureRef {
|
|
||||||
Symbol(Path),
|
|
||||||
Suffix(LitStr),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parse for ProcedureRef {
|
|
||||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
|
||||||
if input.peek(LitStr) {
|
|
||||||
return Ok(Self::Suffix(input.parse()?));
|
|
||||||
}
|
|
||||||
Ok(Self::Symbol(input.parse()?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parse for LeafAssignment {
|
|
||||||
fn parse(input: ParseStream<'_>) -> Result<Self> {
|
|
||||||
let name: Path = input.parse()?;
|
|
||||||
input.parse::<Token![=]>()?;
|
|
||||||
let key = name
|
|
||||||
.get_ident()
|
|
||||||
.ok_or_else(|| Error::new_spanned(&name, "leaf keys must be identifiers"))?
|
|
||||||
.to_string();
|
|
||||||
match key.as_str() {
|
|
||||||
"name" => Ok(Self::Name(input.parse()?)),
|
|
||||||
"id" => Ok(Self::Id(input.parse()?)),
|
|
||||||
"org" => Ok(Self::Org(input.parse()?)),
|
|
||||||
"product" => Ok(Self::Product(input.parse()?)),
|
|
||||||
"version" => Ok(Self::Version(input.parse()?)),
|
|
||||||
"endpoint_struct" | "tui_struct" => Ok(Self::HostBinding(HostBinding {
|
|
||||||
module_path: None,
|
|
||||||
host_path: Some(input.parse()?),
|
|
||||||
})),
|
|
||||||
"endpoint" | "tui" => Ok(Self::HostBinding(HostBinding {
|
|
||||||
module_path: Some(input.parse()?),
|
|
||||||
host_path: None,
|
|
||||||
})),
|
|
||||||
"procedures" => {
|
|
||||||
let content;
|
|
||||||
syn::bracketed!(content in input);
|
|
||||||
let values = Punctuated::<ProcedureRef, Token![,]>::parse_terminated(&content)?
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Self::Procedures(values))
|
|
||||||
}
|
|
||||||
_ => Err(Error::new_spanned(
|
|
||||||
name,
|
|
||||||
"unsupported #[leaf(...)] key; expected one of name, id, org, product, version, procedures, endpoint, tui, endpoint_struct, or tui_struct",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn expand_leaf_declaration(
|
|
||||||
attr: LeafDeclarationAttributes,
|
|
||||||
item: ItemStruct,
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let declaration_ident = item.ident.clone();
|
|
||||||
let id = option_litstr_tokens(attr.id.as_ref());
|
|
||||||
let org = option_litstr_tokens(attr.org.as_ref());
|
|
||||||
let product = option_litstr_tokens(attr.product.as_ref());
|
|
||||||
let version = option_litstr_tokens(attr.version.as_ref());
|
|
||||||
let leaf_name = option_litstr_tokens(attr.name.as_ref());
|
|
||||||
let canonical_procedure_module = attr
|
|
||||||
.host_bindings
|
|
||||||
.iter()
|
|
||||||
.find_map(|binding| binding.module_path.as_ref())
|
|
||||||
.cloned();
|
|
||||||
let procedure_suffixes = attr
|
|
||||||
.procedures
|
|
||||||
.iter()
|
|
||||||
.map(|procedure| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref()))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
let procedure_type_checks = attr
|
|
||||||
.host_bindings
|
|
||||||
.iter()
|
|
||||||
.map(|binding| procedure_type_check_tokens(binding, &attr.procedures, &declaration_ident))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
let host_impls = attr
|
|
||||||
.host_bindings
|
|
||||||
.iter()
|
|
||||||
.map(|binding| expand_binding_impl(binding, &declaration_ident))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#item
|
|
||||||
|
|
||||||
impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident {
|
|
||||||
fn leaf_name() -> ::unshell::alloc::string::String {
|
|
||||||
::unshell::protocol::tree::derive_leaf_name(
|
|
||||||
::core::env!("CARGO_PKG_NAME"),
|
|
||||||
::core::env!("CARGO_PKG_VERSION_MAJOR"),
|
|
||||||
::core::env!("CARGO_PKG_VERSION_MINOR"),
|
|
||||||
::core::env!("CARGO_PKG_VERSION_PATCH"),
|
|
||||||
::core::module_path!(),
|
|
||||||
::core::stringify!(#declaration_ident),
|
|
||||||
#org,
|
|
||||||
#product,
|
|
||||||
#version,
|
|
||||||
#leaf_name,
|
|
||||||
#id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ::unshell::protocol::tree::LeafDeclaration for #declaration_ident {
|
|
||||||
fn procedure_suffixes() -> &'static [&'static str] {
|
|
||||||
&[#(#procedure_suffixes),*]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #declaration_ident {
|
|
||||||
/// Returns the canonical dotted leaf name declared for this surface.
|
|
||||||
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
|
|
||||||
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the canonical protocol leaf metadata for this surface.
|
|
||||||
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
|
|
||||||
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
|
|
||||||
pub fn protocol_procedure_id(
|
|
||||||
suffix: &str,
|
|
||||||
) -> ::core::option::Option<::unshell::alloc::string::String> {
|
|
||||||
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _: fn() = || {
|
|
||||||
#(#procedure_type_checks)*
|
|
||||||
};
|
|
||||||
|
|
||||||
#(#host_impls)*
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_binding_impl(binding: &HostBinding, declaration: &syn::Ident) -> Result<TokenStream> {
|
|
||||||
let host = host_path_for_binding(binding, declaration)?;
|
|
||||||
Ok(quote! {
|
|
||||||
impl ::unshell::protocol::tree::ProtocolLeaf for #host {
|
|
||||||
fn leaf_name() -> ::unshell::alloc::string::String {
|
|
||||||
<#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ::unshell::protocol::tree::LeafBinding for #host {
|
|
||||||
type Declaration = #declaration;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ::unshell::protocol::tree::LeafDeclaration for #host {
|
|
||||||
fn procedure_suffixes() -> &'static [&'static str] {
|
|
||||||
<#declaration as ::unshell::protocol::tree::LeafDeclaration>::procedure_suffixes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #host {
|
|
||||||
/// Returns the canonical dotted leaf name declared for this host.
|
|
||||||
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
|
|
||||||
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the canonical protocol leaf metadata for this host.
|
|
||||||
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
|
|
||||||
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
|
|
||||||
pub fn protocol_procedure_id(
|
|
||||||
suffix: &str,
|
|
||||||
) -> ::core::option::Option<::unshell::alloc::string::String> {
|
|
||||||
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn host_path_for_binding(binding: &HostBinding, declaration: &syn::Ident) -> Result<Path> {
|
|
||||||
if let Some(path) = &binding.host_path {
|
|
||||||
return Ok(path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(module_path) = &binding.module_path else {
|
|
||||||
return Err(Error::new(
|
|
||||||
declaration.span(),
|
|
||||||
"leaf binding is missing a host path",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut path = module_path.clone();
|
|
||||||
path.segments.push(format_ident!("{declaration}").into());
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn procedure_suffix_tokens(
|
|
||||||
procedure: &ProcedureRef,
|
|
||||||
canonical_module: Option<&Path>,
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
match procedure {
|
|
||||||
ProcedureRef::Symbol(procedure) => {
|
|
||||||
let procedure_path = if let Some(module_path) = canonical_module {
|
|
||||||
let mut path = module_path.clone();
|
|
||||||
let ident = procedure.get_ident().ok_or_else(|| {
|
|
||||||
Error::new_spanned(
|
|
||||||
procedure,
|
|
||||||
"procedure names must be bare identifiers when inferred from a module",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
path.segments.push(ident.clone().into());
|
|
||||||
path
|
|
||||||
} else {
|
|
||||||
procedure.clone()
|
|
||||||
};
|
|
||||||
Ok(
|
|
||||||
quote! { <#procedure_path as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ProcedureRef::Suffix(suffix) => Ok(quote! { #suffix }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn procedure_type_check_tokens(
|
|
||||||
binding: &HostBinding,
|
|
||||||
procedures: &[ProcedureRef],
|
|
||||||
declaration: &syn::Ident,
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let Some(module_path) = &binding.module_path else {
|
|
||||||
return Ok(quote! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let checks = procedures
|
|
||||||
.iter()
|
|
||||||
.filter_map(|procedure| match procedure {
|
|
||||||
ProcedureRef::Symbol(procedure) => Some(procedure),
|
|
||||||
ProcedureRef::Suffix(_) => None,
|
|
||||||
})
|
|
||||||
.map(|procedure| {
|
|
||||||
let mut path = module_path.clone();
|
|
||||||
let ident = procedure.get_ident().ok_or_else(|| {
|
|
||||||
Error::new_spanned(
|
|
||||||
procedure,
|
|
||||||
"procedure names must be bare identifiers when inferred from a module",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
path.segments.push(ident.clone().into());
|
|
||||||
Ok::<TokenStream, Error>(quote! {
|
|
||||||
let _ = ::core::marker::PhantomData::<#path>;
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let _ = declaration;
|
|
||||||
Ok(quote! { #(#checks)* })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_once(target: &mut Option<LitStr>, value: LitStr, label: &str) -> Result<()> {
|
|
||||||
if target.is_some() {
|
|
||||||
return Err(Error::new_spanned(value, format!("duplicate {label}")));
|
|
||||||
}
|
|
||||||
*target = Some(value);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
//! Proc macros for `unshell` application-layer leaf declarations.
|
|
||||||
|
|
||||||
mod leaf_decl;
|
|
||||||
mod procedure;
|
|
||||||
mod procedures;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
|
||||||
use syn::{DeriveInput, ItemImpl, ItemStruct, parse_macro_input};
|
|
||||||
|
|
||||||
/// Declares one compile-time leaf surface and binds it to endpoint and/or TUI
|
|
||||||
/// host structs.
|
|
||||||
///
|
|
||||||
/// What it is: an attribute macro placed on a marker struct that generates the
|
|
||||||
/// shared protocol-visible metadata for one leaf and applies that metadata to the
|
|
||||||
/// listed host structs.
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint and TUI hosts should not each have to repeat the leaf
|
|
||||||
/// name and procedure inventory, and endpoint construction should not need a
|
|
||||||
/// handwritten list of procedure ids.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```ignore
|
|
||||||
/// #[unshell::leaf(
|
|
||||||
/// name = "remote_shell",
|
|
||||||
/// procedures = [Open],
|
|
||||||
/// leaf_endpoint = endpoint::RemoteShellEndpoint,
|
|
||||||
/// leaf_tui = tui::RemoteShellTui,
|
|
||||||
/// )]
|
|
||||||
/// pub struct RemoteShell;
|
|
||||||
/// ```
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn leaf(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
match leaf_decl::expand_leaf_declaration(
|
|
||||||
parse_macro_input!(attr as leaf_decl::LeafDeclarationAttributes),
|
|
||||||
parse_macro_input!(item as ItemStruct),
|
|
||||||
) {
|
|
||||||
Ok(tokens) => tokens.into(),
|
|
||||||
Err(error) => error.to_compile_error().into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derives canonical stateful-procedure metadata for one procedure type.
|
|
||||||
///
|
|
||||||
/// What it is: a derive macro that records one procedure suffix and generates
|
|
||||||
/// the canonical `protocol_procedure_id()` helper for that procedure.
|
|
||||||
///
|
|
||||||
/// Why it exists: hook-backed procedures need one stable `procedure_id`, but the
|
|
||||||
/// runtime should not require each procedure to handwrite the identifier logic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```ignore
|
|
||||||
/// use unshell::{Procedure, leaf};
|
|
||||||
///
|
|
||||||
/// #[leaf(
|
|
||||||
/// name = "shell",
|
|
||||||
/// procedures = [OpenSession],
|
|
||||||
/// endpoint_struct = ShellLeaf,
|
|
||||||
/// )]
|
|
||||||
/// struct Shell;
|
|
||||||
///
|
|
||||||
/// struct ShellLeaf;
|
|
||||||
///
|
|
||||||
/// #[derive(Procedure)]
|
|
||||||
/// #[procedure(leaf = ShellLeaf, name = "open")]
|
|
||||||
/// struct OpenSession;
|
|
||||||
///
|
|
||||||
/// assert!(OpenSession::protocol_procedure_id().ends_with(".open"));
|
|
||||||
/// ```
|
|
||||||
#[proc_macro_derive(Procedure, attributes(procedure))]
|
|
||||||
pub fn derive_procedure(input: TokenStream) -> TokenStream {
|
|
||||||
match procedure::expand_procedure(parse_macro_input!(input as DeriveInput)) {
|
|
||||||
Ok(tokens) => tokens.into(),
|
|
||||||
Err(error) => error.to_compile_error().into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates dispatch glue for a simple call-driven leaf impl block.
|
|
||||||
///
|
|
||||||
/// What it is: an attribute macro placed on one `impl` block whose `#[call]`
|
|
||||||
/// methods define the callable surface for that leaf.
|
|
||||||
///
|
|
||||||
/// Why it exists: one-shot leaves should be able to declare a small RPC-like API
|
|
||||||
/// on ordinary Rust methods while still producing the canonical procedure list
|
|
||||||
/// and dispatch logic expected by the protocol runtime.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```ignore
|
|
||||||
/// use unshell::{leaf, procedures};
|
|
||||||
///
|
|
||||||
/// #[leaf(
|
|
||||||
/// id = "org.example.v1.echo",
|
|
||||||
/// procedures = ["echo"],
|
|
||||||
/// endpoint_struct = EchoLeaf,
|
|
||||||
/// )]
|
|
||||||
/// struct Echo;
|
|
||||||
///
|
|
||||||
/// struct EchoLeaf;
|
|
||||||
///
|
|
||||||
/// #[procedures(error = core::convert::Infallible)]
|
|
||||||
/// impl EchoLeaf {
|
|
||||||
/// #[call]
|
|
||||||
/// fn echo(&mut self, input: String) -> String {
|
|
||||||
/// input
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// assert!(EchoLeaf::protocol_procedure_id("echo").is_some());
|
|
||||||
/// ```
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn procedures(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
match procedures::expand_procedures(
|
|
||||||
parse_macro_input!(attr as procedures::ProceduresAttributes),
|
|
||||||
parse_macro_input!(item as ItemImpl),
|
|
||||||
) {
|
|
||||||
Ok(tokens) => tokens.into(),
|
|
||||||
Err(error) => error.to_compile_error().into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
use quote::quote;
|
|
||||||
use syn::{Attribute, Data, DeriveInput, Error, LitStr, Result, Type};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ProcedureAttributes {
|
|
||||||
leaf: Option<Type>,
|
|
||||||
name: Option<LitStr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureAttributes {
|
|
||||||
fn parse_from(attrs: &[Attribute]) -> Result<Self> {
|
|
||||||
let mut parsed = Self::default();
|
|
||||||
|
|
||||||
for attr in attrs {
|
|
||||||
if !attr.path().is_ident("procedure") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
attr.parse_nested_meta(|meta| {
|
|
||||||
if meta.path.is_ident("leaf") {
|
|
||||||
if parsed.leaf.is_some() {
|
|
||||||
return Err(meta.error("duplicate procedure leaf attribute"));
|
|
||||||
}
|
|
||||||
parsed.leaf = Some(meta.value()?.parse()?);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if meta.path.is_ident("name") {
|
|
||||||
if parsed.name.is_some() {
|
|
||||||
return Err(meta.error("duplicate procedure name attribute"));
|
|
||||||
}
|
|
||||||
parsed.name = Some(meta.value()?.parse()?);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(meta.error("unsupported #[procedure(...)] attribute"))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn expand_procedure(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
|
|
||||||
let procedure_name = input.ident;
|
|
||||||
match input.data {
|
|
||||||
Data::Struct(_) => {}
|
|
||||||
_ => {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
procedure_name,
|
|
||||||
"Procedure can only be derived for structs",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed = ProcedureAttributes::parse_from(&input.attrs)?;
|
|
||||||
let leaf_ty = parsed.leaf.ok_or_else(|| {
|
|
||||||
Error::new_spanned(
|
|
||||||
&procedure_name,
|
|
||||||
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let suffix = parsed.name.ok_or_else(|| {
|
|
||||||
Error::new_spanned(
|
|
||||||
&procedure_name,
|
|
||||||
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if suffix.value().is_empty() {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&suffix,
|
|
||||||
"procedure name must not be empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if suffix.value().contains('.') {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&suffix,
|
|
||||||
"procedure name must be one local suffix without dots",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if suffix.value().chars().any(char::is_whitespace) {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&suffix,
|
|
||||||
"procedure name must not contain whitespace",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
impl #impl_generics ::unshell::protocol::tree::ProcedureMetadata
|
|
||||||
for #procedure_name #ty_generics #where_clause
|
|
||||||
where
|
|
||||||
#leaf_ty: ::unshell::protocol::tree::ProtocolLeaf,
|
|
||||||
{
|
|
||||||
type Leaf = #leaf_ty;
|
|
||||||
|
|
||||||
const PROCEDURE_SUFFIX: &'static str = #suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #impl_generics #procedure_name #ty_generics #where_clause {
|
|
||||||
/// Returns the full canonical `procedure_id` for this stateful procedure.
|
|
||||||
pub fn protocol_procedure_id() -> ::unshell::alloc::string::String {
|
|
||||||
<Self as ::unshell::protocol::tree::ProcedureMetadata>::procedure_id()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
use proc_macro2::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{
|
|
||||||
Error, FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, LitStr, PatType, Result, ReturnType,
|
|
||||||
Token, Type, parse::Parse, punctuated::Punctuated,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::utils::{
|
|
||||||
extract_outer_type_argument, extract_result_type_arguments, is_unit_type, take_call_attr,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct ProceduresAttributes {
|
|
||||||
error: Option<Type>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parse for ProceduresAttributes {
|
|
||||||
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
|
|
||||||
if input.is_empty() {
|
|
||||||
return Ok(Self::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parsed = Self::default();
|
|
||||||
let assignments = Punctuated::<Assignment, Token![,]>::parse_terminated(input)?;
|
|
||||||
for assignment in assignments {
|
|
||||||
if assignment.name == "error" {
|
|
||||||
if parsed.error.is_some() {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
assignment.name,
|
|
||||||
"duplicate procedures error attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
parsed.error = Some(assignment.value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
assignment.name,
|
|
||||||
"unsupported #[procedures(...)] attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Assignment {
|
|
||||||
name: Ident,
|
|
||||||
value: Type,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parse for Assignment {
|
|
||||||
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
name: input.parse()?,
|
|
||||||
value: {
|
|
||||||
input.parse::<Token![=]>()?;
|
|
||||||
input.parse()?
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CallArm {
|
|
||||||
suffix_literal: LitStr,
|
|
||||||
dispatch_tokens: TokenStream,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum EndpointArgKind {
|
|
||||||
Shared,
|
|
||||||
Mutable,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn expand_procedures(
|
|
||||||
attr: ProceduresAttributes,
|
|
||||||
mut item: ItemImpl,
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let self_ty = item.self_ty.clone();
|
|
||||||
let impl_generics = item.generics.clone();
|
|
||||||
let (impl_generics_tokens, _ty_generics, where_clause) = impl_generics.split_for_impl();
|
|
||||||
let error_ty = attr.error.ok_or_else(|| {
|
|
||||||
Error::new_spanned(
|
|
||||||
&item.self_ty,
|
|
||||||
"missing #[procedures(error = MyError)] attribute",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut dispatch_arms = Vec::new();
|
|
||||||
let mut seen_suffixes = std::collections::BTreeSet::new();
|
|
||||||
|
|
||||||
for impl_item in &mut item.items {
|
|
||||||
let ImplItem::Fn(method) = impl_item else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let has_call_attr = method.attrs.iter().any(|attr| attr.path().is_ident("call"));
|
|
||||||
if !has_call_attr {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let arm = expand_call_arm(method)?;
|
|
||||||
take_call_attr(&mut method.attrs);
|
|
||||||
if !seen_suffixes.insert(arm.suffix_literal.value()) {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
method,
|
|
||||||
"duplicate #[call] procedure suffix in this impl block",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
dispatch_arms.push(arm);
|
|
||||||
}
|
|
||||||
|
|
||||||
if dispatch_arms.is_empty() {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&item.self_ty,
|
|
||||||
"#[procedures] requires at least one #[call] method",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone());
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#item
|
|
||||||
|
|
||||||
impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause {
|
|
||||||
type Error = #error_ty;
|
|
||||||
|
|
||||||
fn dispatch_call(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ::unshell::protocol::tree::ProtocolEndpoint,
|
|
||||||
call: ::unshell::protocol::tree::IncomingCall,
|
|
||||||
) -> ::core::result::Result<
|
|
||||||
::unshell::protocol::tree::CallReply,
|
|
||||||
::unshell::protocol::tree::DispatchError<Self::Error>,
|
|
||||||
> {
|
|
||||||
#(#dispatch_checks)*
|
|
||||||
unreachable!("protocol runtime validated local procedure dispatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_call_arm(method: &ImplItemFn) -> Result<CallArm> {
|
|
||||||
let method_name = &method.sig.ident;
|
|
||||||
let suffix_literal = call_suffix_literal(method)?;
|
|
||||||
let call_id_expr = quote! {
|
|
||||||
{
|
|
||||||
let mut __unshell_id = <Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name();
|
|
||||||
__unshell_id.push('.');
|
|
||||||
__unshell_id.push_str(#suffix_literal);
|
|
||||||
__unshell_id
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let inputs = method
|
|
||||||
.sig
|
|
||||||
.inputs
|
|
||||||
.iter()
|
|
||||||
.filter(|input| !matches!(input, FnArg::Receiver(_)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let (endpoint_arg, inputs) = split_endpoint_arg(&inputs)?;
|
|
||||||
|
|
||||||
let invocation = expand_invocation(method_name, endpoint_arg, &inputs)?;
|
|
||||||
let return_value = expand_return_conversion(&method.sig.output, quote! { __unshell_result })?;
|
|
||||||
|
|
||||||
Ok(CallArm {
|
|
||||||
suffix_literal: suffix_literal.clone(),
|
|
||||||
dispatch_tokens: quote! {
|
|
||||||
if call.message.procedure_id == #call_id_expr {
|
|
||||||
let __unshell_result = #invocation;
|
|
||||||
return { #return_value };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_invocation(
|
|
||||||
method_name: &Ident,
|
|
||||||
endpoint_arg: Option<EndpointArgKind>,
|
|
||||||
inputs: &[&FnArg],
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let endpoint_prefix = endpoint_arg.map(endpoint_arg_tokens);
|
|
||||||
if inputs.is_empty() {
|
|
||||||
return Ok(if let Some(prefix) = endpoint_prefix {
|
|
||||||
quote! { self.#method_name(#prefix) }
|
|
||||||
} else {
|
|
||||||
quote! { self.#method_name() }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if inputs.len() == 1 {
|
|
||||||
let FnArg::Typed(PatType { ty, .. }) = inputs[0] else {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
inputs[0],
|
|
||||||
"unsupported receiver in procedure signature",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(inner) = extract_call_inner_type(ty) {
|
|
||||||
return Ok(quote! {{
|
|
||||||
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#inner>(
|
|
||||||
call.message.data.as_slice(),
|
|
||||||
)
|
|
||||||
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
|
|
||||||
// Rebuild the normalized `Call<T>` value expected by generated handlers from the
|
|
||||||
// validated protocol envelope plus the typed payload we just decoded.
|
|
||||||
let __unshell_call = ::unshell::protocol::tree::Call {
|
|
||||||
input: __unshell_input,
|
|
||||||
caller_path: call.header.src_path.clone(),
|
|
||||||
procedure_id: call.message.procedure_id.clone(),
|
|
||||||
dst_leaf: call.header.dst_leaf.clone(),
|
|
||||||
response_hook: call
|
|
||||||
.message
|
|
||||||
.response_hook
|
|
||||||
.as_ref()
|
|
||||||
.map(|hook| ::unshell::protocol::tree::HookKey::new(
|
|
||||||
hook.return_path.clone(),
|
|
||||||
hook.hook_id,
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
self.#method_name(#endpoint_prefix __unshell_call)
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(quote! {{
|
|
||||||
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#ty>(
|
|
||||||
call.message.data.as_slice(),
|
|
||||||
)
|
|
||||||
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
|
|
||||||
self.#method_name(#endpoint_prefix __unshell_input)
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
let tuple_types = inputs
|
|
||||||
.iter()
|
|
||||||
.map(|input| match input {
|
|
||||||
FnArg::Typed(PatType { ty, .. }) => Ok(ty.clone()),
|
|
||||||
other => Err(Error::new_spanned(
|
|
||||||
other,
|
|
||||||
"unsupported receiver in procedure signature",
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
let vars = (0..tuple_types.len())
|
|
||||||
.map(|index| format_ident!("__unshell_arg_{index}"))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(quote! {{
|
|
||||||
let (#(#vars),*) = ::unshell::protocol::tree::decode_call_input::<(#(#tuple_types),*)>(
|
|
||||||
call.message.data.as_slice(),
|
|
||||||
)
|
|
||||||
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
|
|
||||||
self.#method_name(#endpoint_prefix #(#vars),*)
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_endpoint_arg<'a>(
|
|
||||||
inputs: &[&'a FnArg],
|
|
||||||
) -> Result<(Option<EndpointArgKind>, Vec<&'a FnArg>)> {
|
|
||||||
let Some(first) = inputs.first() else {
|
|
||||||
return Ok((None, Vec::new()));
|
|
||||||
};
|
|
||||||
let Some(kind) = endpoint_arg_kind(first)? else {
|
|
||||||
return Ok((None, inputs.to_vec()));
|
|
||||||
};
|
|
||||||
Ok((Some(kind), inputs[1..].to_vec()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn endpoint_arg_kind(arg: &FnArg) -> Result<Option<EndpointArgKind>> {
|
|
||||||
let FnArg::Typed(PatType { ty, .. }) = arg else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Type::Reference(reference) = ty.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Type::Path(type_path) = reference.elem.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(segment) = type_path.path.segments.last() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if segment.ident != "ProtocolEndpoint" {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some(if reference.mutability.is_some() {
|
|
||||||
EndpointArgKind::Mutable
|
|
||||||
} else {
|
|
||||||
EndpointArgKind::Shared
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn endpoint_arg_tokens(kind: EndpointArgKind) -> TokenStream {
|
|
||||||
match kind {
|
|
||||||
EndpointArgKind::Shared => quote! { &*endpoint, },
|
|
||||||
EndpointArgKind::Mutable => quote! { endpoint, },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_return_conversion(return_type: &ReturnType, value: TokenStream) -> Result<TokenStream> {
|
|
||||||
match return_type {
|
|
||||||
ReturnType::Default => Ok(quote! {
|
|
||||||
let _ = #value;
|
|
||||||
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
|
|
||||||
}),
|
|
||||||
ReturnType::Type(_, ty) => normalize_output_type(ty, value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_output_type(ty: &Type, value: TokenStream) -> Result<TokenStream> {
|
|
||||||
if is_unit_type(ty) {
|
|
||||||
return Ok(quote! {
|
|
||||||
let _ = #value;
|
|
||||||
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(inner) = extract_outer_type_argument(ty, "CallResult") {
|
|
||||||
let inner_conversion = normalize_reply_value(inner, quote! { __unshell_value })?;
|
|
||||||
return Ok(quote! {
|
|
||||||
match #value {
|
|
||||||
::unshell::protocol::tree::CallResult::Reply(__unshell_value) => {
|
|
||||||
#inner_conversion
|
|
||||||
}
|
|
||||||
::unshell::protocol::tree::CallResult::NoReply => {
|
|
||||||
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((ok_ty, _error_ty)) = extract_result_type_arguments(ty) {
|
|
||||||
let ok_conversion = normalize_output_type(ok_ty, quote! { __unshell_value })?;
|
|
||||||
return Ok(quote! {
|
|
||||||
match #value {
|
|
||||||
::core::result::Result::Ok(__unshell_value) => { #ok_conversion }
|
|
||||||
::core::result::Result::Err(__unshell_error) => {
|
|
||||||
::core::result::Result::Err(
|
|
||||||
::unshell::protocol::tree::DispatchError::Handler(__unshell_error)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize_reply_value(ty, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_reply_value(_ty: &Type, value: TokenStream) -> Result<TokenStream> {
|
|
||||||
Ok(quote! {
|
|
||||||
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::Reply(
|
|
||||||
::unshell::protocol::tree::encode_call_reply(&#value)
|
|
||||||
.map_err(::unshell::protocol::tree::DispatchError::Encode)?
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_call_inner_type(ty: &Type) -> Option<&Type> {
|
|
||||||
extract_outer_type_argument(ty, "Call")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_suffix_literal(method: &ImplItemFn) -> Result<LitStr> {
|
|
||||||
let mut suffix = None;
|
|
||||||
|
|
||||||
for attr in &method.attrs {
|
|
||||||
if !attr.path().is_ident("call") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(attr.meta, syn::Meta::Path(_)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
attr.parse_nested_meta(|meta| {
|
|
||||||
if meta.path.is_ident("name") {
|
|
||||||
if suffix.is_some() {
|
|
||||||
return Err(meta.error("duplicate call name attribute"));
|
|
||||||
}
|
|
||||||
suffix = Some(meta.value()?.parse()?);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(meta.error("unsupported #[call(...)] attribute"))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let suffix = suffix
|
|
||||||
.unwrap_or_else(|| LitStr::new(&method.sig.ident.to_string(), method.sig.ident.span()));
|
|
||||||
if suffix.value().is_empty() {
|
|
||||||
return Err(Error::new_spanned(&suffix, "call name must not be empty"));
|
|
||||||
}
|
|
||||||
if suffix.value().contains('.') {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&suffix,
|
|
||||||
"call name must be one local suffix without dots",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if suffix.value().chars().any(char::is_whitespace) {
|
|
||||||
return Err(Error::new_spanned(
|
|
||||||
&suffix,
|
|
||||||
"call name must not contain whitespace",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(suffix)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
use proc_macro2::TokenStream;
|
|
||||||
use quote::quote;
|
|
||||||
use syn::{Attribute, GenericArgument, LitStr, Type, TypePath};
|
|
||||||
|
|
||||||
pub(crate) fn option_litstr_tokens(value: Option<&LitStr>) -> TokenStream {
|
|
||||||
match value {
|
|
||||||
Some(value) => quote! { ::core::option::Option::Some(#value) },
|
|
||||||
None => quote! { ::core::option::Option::None },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn extract_outer_type_argument<'a>(ty: &'a Type, expected: &str) -> Option<&'a Type> {
|
|
||||||
let Type::Path(TypePath { path, .. }) = ty else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let segment = path.segments.last()?;
|
|
||||||
if segment.ident != expected {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
match arguments.args.first()? {
|
|
||||||
GenericArgument::Type(inner) => Some(inner),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn extract_result_type_arguments(ty: &Type) -> Option<(&Type, &Type)> {
|
|
||||||
let Type::Path(TypePath { path, .. }) = ty else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let segment = path.segments.last()?;
|
|
||||||
if segment.ident != "Result" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let mut args = arguments.args.iter();
|
|
||||||
let ok = match args.next()? {
|
|
||||||
GenericArgument::Type(value) => value,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let err = match args.next()? {
|
|
||||||
GenericArgument::Type(value) => value,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some((ok, err))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_unit_type(ty: &Type) -> bool {
|
|
||||||
matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_call_attr(attrs: &mut Vec<Attribute>) -> bool {
|
|
||||||
let original_len = attrs.len();
|
|
||||||
attrs.retain(|attr| !attr.path().is_ident("call"));
|
|
||||||
original_len != attrs.len()
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
//! # UnShell Protocol
|
|
||||||
//!
|
|
||||||
//! The protocol crate owns the wire types, framing, validation helpers, and the
|
|
||||||
//! small tree runtime used by endpoint implementations.
|
|
||||||
|
|
||||||
#![no_std]
|
|
||||||
|
|
||||||
pub extern crate alloc;
|
|
||||||
#[allow(unused_extern_crates)]
|
|
||||||
extern crate self as unshell;
|
|
||||||
|
|
||||||
/// Keep the historical nested path so existing imports and proc-macro output can
|
|
||||||
/// continue to target `unshell::protocol::...` while the implementation lives in
|
|
||||||
/// its own crate.
|
|
||||||
pub mod protocol;
|
|
||||||
|
|
||||||
pub use protocol::*;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub use unshell_macros::{Procedure, leaf, procedures};
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# Protocol Change Pressure
|
|
||||||
|
|
||||||
This document records protocol-spec changes that are worth considering after the
|
|
||||||
runtime rewrite in `src/protocol`.
|
|
||||||
|
|
||||||
The current rewrite intentionally keeps the existing wire model from
|
|
||||||
`/home/astatin3/Documents/GitHub/unshell/PROTOCOL.md` wherever possible. The main
|
|
||||||
goal was to remove avoidable runtime work without silently drifting the protocol.
|
|
||||||
|
|
||||||
The implementation now does the following:
|
|
||||||
|
|
||||||
- compiles child routing prefixes once instead of scanning child paths on every packet
|
|
||||||
- routes from the header first, then decodes payloads only on local delivery
|
|
||||||
- keeps pending hook state minimal and active hook state directly indexed
|
|
||||||
- separates local typed send paths from framed transport-facing send paths
|
|
||||||
|
|
||||||
Those are implementation changes. They do not require a protocol update.
|
|
||||||
|
|
||||||
## Implemented Deviation
|
|
||||||
|
|
||||||
The current scratch rewrite **does** deviate from the frame format described in
|
|
||||||
`PROTOCOL.md` Section 8.
|
|
||||||
|
|
||||||
The old format used one `u32` length prefix immediately before each archived
|
|
||||||
section. The new implementation uses one aligned two-section frame:
|
|
||||||
|
|
||||||
- `u32 header_len`
|
|
||||||
- `u32 payload_len`
|
|
||||||
- aligned archived header bytes
|
|
||||||
- aligned archived payload bytes
|
|
||||||
|
|
||||||
The payload start is padded up to the canonical archive alignment boundary.
|
|
||||||
|
|
||||||
This deviation was made explicitly because the prior layout baked in alignment
|
|
||||||
repair complexity and extra decode copies even in an otherwise clean runtime.
|
|
||||||
|
|
||||||
## No Immediate Semantic Change Required
|
|
||||||
|
|
||||||
Aside from the framing change above, the current runtime rewrite does **not**
|
|
||||||
require a semantic protocol break.
|
|
||||||
|
|
||||||
The following parts of `PROTOCOL.md` remain worth keeping as-is:
|
|
||||||
|
|
||||||
- path-based routing remains the canonical behavior
|
|
||||||
- pending call context remains distinct from active hook state
|
|
||||||
- `Fault` remains upstream-only
|
|
||||||
- unknown or expired `hook_id` still drops returned traffic
|
|
||||||
- hook closure still requires both sides to send `end_hook = true`, or one `Fault`
|
|
||||||
|
|
||||||
Those rules keep the protocol boring and interoperable.
|
|
||||||
|
|
||||||
## Change 1: Framing That Guarantees Archive Alignment
|
|
||||||
|
|
||||||
### Current problem
|
|
||||||
|
|
||||||
`PROTOCOL.md` Section 8 fixes a framed format with a 4-byte big-endian length
|
|
||||||
prefix before each archived section.
|
|
||||||
|
|
||||||
That is simple, but it has one hard performance downside in the current Rust
|
|
||||||
implementation:
|
|
||||||
|
|
||||||
- the start of the archived section is not guaranteed to satisfy `rkyv` alignment
|
|
||||||
- the decoder therefore has to copy header bytes into an `AlignedVec` before safe access
|
|
||||||
- local payload decode also copies the payload bytes into another `AlignedVec`
|
|
||||||
|
|
||||||
This means the runtime still performs unavoidable memory copies during decode even
|
|
||||||
after the architectural cleanup.
|
|
||||||
|
|
||||||
### Recommended protocol change
|
|
||||||
|
|
||||||
Revise the framing rules so each archived section begins at a guaranteed aligned
|
|
||||||
offset.
|
|
||||||
|
|
||||||
Two viable options:
|
|
||||||
|
|
||||||
1. Add explicit padding after each length field so the archived section begins at
|
|
||||||
the required alignment boundary.
|
|
||||||
2. Replace the current two-section frame with one canonical aligned envelope type
|
|
||||||
whose internal layout already satisfies the archive alignment rules.
|
|
||||||
|
|
||||||
### Why this is objectively better
|
|
||||||
|
|
||||||
- removes the forced alignment-copy step on decode
|
|
||||||
- makes zero-copy or near-zero-copy archived access actually achievable
|
|
||||||
- reduces local delivery latency for all packet types
|
|
||||||
- reduces transient allocation pressure in the decoder
|
|
||||||
|
|
||||||
### Tradeoff
|
|
||||||
|
|
||||||
This is a wire-format change. Every compliant implementation would need to adopt
|
|
||||||
the new framing.
|
|
||||||
|
|
||||||
### Status
|
|
||||||
|
|
||||||
Implemented in the current rewrite.
|
|
||||||
|
|
||||||
## Change 2: Compact Path Representation for a Future v2
|
|
||||||
|
|
||||||
### Current problem
|
|
||||||
|
|
||||||
`PROTOCOL.md` Sections 5, 6, 10, 11, and 13 make paths canonical on the wire as
|
|
||||||
`Vec<String>` values.
|
|
||||||
|
|
||||||
That is easy to understand and debug, but it imposes real cost:
|
|
||||||
|
|
||||||
- path routing requires segment-wise string comparison
|
|
||||||
- hook state keys carry owned path vectors
|
|
||||||
- packets repeat full path strings over and over
|
|
||||||
- the runtime must repeatedly compare or clone path structures at boundaries
|
|
||||||
|
|
||||||
The new implementation minimizes those costs internally, but it cannot eliminate
|
|
||||||
them while the wire format remains path-string based.
|
|
||||||
|
|
||||||
### Recommended protocol change
|
|
||||||
|
|
||||||
For a future protocol version, consider separating:
|
|
||||||
|
|
||||||
- the canonical human-readable control/discovery layer
|
|
||||||
- the compact transport/runtime layer
|
|
||||||
|
|
||||||
The compact transport/runtime layer would use stable numeric endpoint IDs instead
|
|
||||||
of repeated `Vec<String>` path payloads.
|
|
||||||
|
|
||||||
### Why this is objectively better
|
|
||||||
|
|
||||||
- routing becomes integer-based instead of string-prefix based
|
|
||||||
- hook keys become compact and cheap to index
|
|
||||||
- packets shrink
|
|
||||||
- path comparisons and many path clones disappear from the hot path
|
|
||||||
|
|
||||||
### Tradeoff
|
|
||||||
|
|
||||||
This is a full protocol-versioning decision, not a local cleanup.
|
|
||||||
|
|
||||||
It adds coordination costs:
|
|
||||||
|
|
||||||
- peers must agree on endpoint IDs
|
|
||||||
- topology updates become more structured
|
|
||||||
- the protocol becomes less self-describing on the wire
|
|
||||||
|
|
||||||
### Recommendation
|
|
||||||
|
|
||||||
Do **not** make this change as a silent update to the current protocol.
|
|
||||||
|
|
||||||
If pursued, it should be introduced explicitly as a `v2` protocol, because it is
|
|
||||||
no longer behaviorally equivalent to the current path-based wire model.
|
|
||||||
|
|
||||||
## Change 3: Clarify Caller-Side Hook Activation Semantics
|
|
||||||
|
|
||||||
### Current problem
|
|
||||||
|
|
||||||
`PROTOCOL.md` Section 13 is explicit about callee-side pending call context, but
|
|
||||||
it leaves more room for interpretation on the caller side after a `Call` is sent.
|
|
||||||
|
|
||||||
The current runtime keeps caller-side hook state available immediately after send
|
|
||||||
so it can validate returned traffic efficiently.
|
|
||||||
|
|
||||||
That is practical, but the spec could be clearer about whether the caller's local
|
|
||||||
hook record is considered active immediately, or merely reserved until the callee
|
|
||||||
accepts.
|
|
||||||
|
|
||||||
### Recommended protocol change
|
|
||||||
|
|
||||||
Clarify caller-side wording in Section 13 so implementations know whether the
|
|
||||||
caller may allocate directly into active host state after sending a `Call`, as
|
|
||||||
long as early returned `Data` for an actually inactive hook is still discarded per
|
|
||||||
Section 14.1.
|
|
||||||
|
|
||||||
### Why this is objectively better
|
|
||||||
|
|
||||||
- removes ambiguity for optimized runtimes
|
|
||||||
- makes caller-side hook bookkeeping more consistent across implementations
|
|
||||||
- avoids accidental spec drift through inference
|
|
||||||
|
|
||||||
### Tradeoff
|
|
||||||
|
|
||||||
This is a clarification change, not necessarily a wire-format change.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The runtime rewrite shows that most of the original performance problems were
|
|
||||||
architectural, not inherent to the protocol.
|
|
||||||
|
|
||||||
The current protocol can support a much lower-loop implementation than before.
|
|
||||||
|
|
||||||
The main remaining protocol-level blocker is the framing/alignment rule. That is
|
|
||||||
the one change most worth making if the next goal is to reduce unavoidable memory
|
|
||||||
copies further.
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
//! Framed packet encoding and decoding.
|
|
||||||
use core::{fmt, mem};
|
|
||||||
use rkyv::{
|
|
||||||
Serialize, access, api::high::to_bytes_in, deserialize, rancor::Error, util::AlignedVec,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::types::{
|
|
||||||
ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage, ArchivedPacketHeader,
|
|
||||||
};
|
|
||||||
use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType};
|
|
||||||
|
|
||||||
/// Archived-section alignment guaranteed by the frame format.
|
|
||||||
///
|
|
||||||
/// The protocol aligns both archived sections so `rkyv` can usually validate and deserialize
|
|
||||||
/// them without first copying into a temporary aligned buffer.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::SECTION_ALIGN;
|
|
||||||
/// assert_eq!(SECTION_ALIGN, 16);
|
|
||||||
/// ```
|
|
||||||
pub const SECTION_ALIGN: usize = 16;
|
|
||||||
|
|
||||||
/// Owned framed packet bytes.
|
|
||||||
///
|
|
||||||
/// This is the concrete buffer type returned by [`encode_packet`]. It keeps archived packet bytes
|
|
||||||
/// aligned according to [`SECTION_ALIGN`] so decode can often stay zero-copy.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["root".into(), "worker".into()],
|
|
||||||
/// dst_leaf: Some("service".into()),
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let message = CallMessage {
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// };
|
|
||||||
/// let frame: FrameBytes = encode_packet(&header, &message)?;
|
|
||||||
/// assert!(!frame.is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub type FrameBytes = AlignedVec<SECTION_ALIGN>;
|
|
||||||
|
|
||||||
/// Framing or archive failure.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum FrameError {
|
|
||||||
/// The byte slice ended before a full frame could be decoded.
|
|
||||||
Truncated,
|
|
||||||
/// The archived header bytes failed validation or deserialization.
|
|
||||||
InvalidHeader(Error),
|
|
||||||
/// The archived payload bytes failed validation or deserialization.
|
|
||||||
InvalidPayload(Error),
|
|
||||||
/// Serializing one header or payload section failed.
|
|
||||||
Serialize(Error),
|
|
||||||
/// One archived section grew beyond the `u32` length prefix supported by the format.
|
|
||||||
LengthOverflow,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for FrameError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Truncated => f.write_str("truncated frame"),
|
|
||||||
Self::InvalidHeader(error) => write!(f, "invalid archived header: {error}"),
|
|
||||||
Self::InvalidPayload(error) => write!(f, "invalid archived payload: {error}"),
|
|
||||||
Self::Serialize(error) => write!(f, "serialization failed: {error}"),
|
|
||||||
Self::LengthOverflow => f.write_str("framed section exceeds u32 length"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::error::Error for FrameError {}
|
|
||||||
|
|
||||||
/// Parsed frame with one owned header and a borrowed payload section.
|
|
||||||
///
|
|
||||||
/// The frame decoder eagerly materializes the routing header into owned Rust values, but keeps
|
|
||||||
/// the payload section borrowed so callers can choose which concrete payload type to decode.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["root".into(), "worker".into()],
|
|
||||||
/// dst_leaf: Some("service".into()),
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let message = CallMessage {
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// data: vec![7; 4],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&header, &message)?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert_eq!(parsed.packet_type(), PacketType::Call);
|
|
||||||
/// let decoded = parsed.deserialize_call()?;
|
|
||||||
/// assert_eq!(decoded.data.len(), 4);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub struct ParsedFrame<'a> {
|
|
||||||
header: PacketHeader,
|
|
||||||
payload_bytes: &'a [u8],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ParsedFrame<'a> {
|
|
||||||
#[must_use]
|
|
||||||
/// Returns the decoded packet header.
|
|
||||||
///
|
|
||||||
/// This exists so callers can inspect routing metadata before deciding which payload schema
|
|
||||||
/// to decode.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&header, &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// })?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert_eq!(parsed.header().packet_type, PacketType::Call);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn header(&self) -> &PacketHeader {
|
|
||||||
&self.header
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Returns the packet class from the decoded header.
|
|
||||||
///
|
|
||||||
/// This exists as a cheap dispatch helper so callers do not have to reach into the header
|
|
||||||
/// struct directly when branching on payload type.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&header, &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// })?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert!(matches!(parsed.packet_type(), PacketType::Call));
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn packet_type(&self) -> PacketType {
|
|
||||||
self.header.packet_type
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Returns the borrowed payload section bytes.
|
|
||||||
///
|
|
||||||
/// This exists for callers that embed their own archived application payloads inside protocol
|
|
||||||
/// `data` fields and want to defer typed decoding.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&header, &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1, 2, 3],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// })?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert!(!parsed.payload_bytes().is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn payload_bytes(&self) -> &'a [u8] {
|
|
||||||
self.payload_bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Splits the parsed frame into its owned header and borrowed payload bytes.
|
|
||||||
///
|
|
||||||
/// This exists when callers want to take ownership of the decoded header while still choosing
|
|
||||||
/// how and when to interpret the payload bytes.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let header = PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&header, &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// })?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// let (owned_header, payload) = parsed.into_parts();
|
|
||||||
/// assert_eq!(owned_header.packet_type, PacketType::Call);
|
|
||||||
/// assert!(!payload.is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn into_parts(self) -> (PacketHeader, &'a [u8]) {
|
|
||||||
(self.header, self.payload_bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserializes the payload section as a [`CallMessage`].
|
|
||||||
///
|
|
||||||
/// This exists so callers can decode a validated `Call` packet payload without spelling the
|
|
||||||
/// archived-type details themselves.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let message = CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// }, &message)?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert_eq!(parsed.deserialize_call()?.procedure_id, message.procedure_id);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
|
|
||||||
self.deserialize_payload::<ArchivedCallMessage, CallMessage>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserializes the payload section as a [`DataMessage`].
|
|
||||||
///
|
|
||||||
/// This exists so callers can decode hook `Data` payloads without reaching for the generic
|
|
||||||
/// archived helper directly.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{DataMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let message = DataMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1],
|
|
||||||
/// end_hook: false,
|
|
||||||
/// };
|
|
||||||
/// let frame = encode_packet(&PacketHeader {
|
|
||||||
/// packet_type: PacketType::Data,
|
|
||||||
/// src_path: vec!["worker".into()],
|
|
||||||
/// dst_path: vec!["root".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: Some(7),
|
|
||||||
/// }, &message)?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert!(!parsed.deserialize_data()?.end_hook);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
|
|
||||||
self.deserialize_payload::<ArchivedDataMessage, DataMessage>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserializes the payload section as a [`FaultMessage`].
|
|
||||||
///
|
|
||||||
/// This exists so callers can decode protocol faults with the same selective API used for
|
|
||||||
/// call and data packets.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault, decode_frame, encode_packet};
|
|
||||||
/// let frame = encode_packet(&PacketHeader {
|
|
||||||
/// packet_type: PacketType::Fault,
|
|
||||||
/// src_path: vec!["worker".into()],
|
|
||||||
/// dst_path: vec!["root".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: Some(7),
|
|
||||||
/// }, &FaultMessage { fault: ProtocolFault::INTERNAL_ERROR })?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert_eq!(parsed.deserialize_fault()?.fault, ProtocolFault::INTERNAL_ERROR);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
|
|
||||||
self.deserialize_payload::<ArchivedFaultMessage, FaultMessage>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_payload<A, T>(&self) -> Result<T, FrameError>
|
|
||||||
where
|
|
||||||
A: rkyv::Portable
|
|
||||||
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
|
|
||||||
T: rkyv::Archive,
|
|
||||||
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
|
|
||||||
{
|
|
||||||
deserialize_archived_bytes::<A, T>(self.payload_bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes a packet header and payload using the aligned two-section frame format.
|
|
||||||
///
|
|
||||||
/// The frame starts with two big-endian `u32` lengths, followed by an aligned archived header
|
|
||||||
/// section and an aligned archived payload section. Both sections use [`SECTION_ALIGN`] so the
|
|
||||||
/// archived bytes can usually be accessed without a fallback copy on decode.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet};
|
|
||||||
/// let frame = encode_packet(
|
|
||||||
/// &PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: Some("service".into()),
|
|
||||||
/// hook_id: None,
|
|
||||||
/// },
|
|
||||||
/// &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1, 2, 3],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// },
|
|
||||||
/// )?;
|
|
||||||
/// assert!(frame.len() >= 8);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
|
|
||||||
where
|
|
||||||
P: for<'a> Serialize<
|
|
||||||
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
let header_start = align_up(8usize, SECTION_ALIGN);
|
|
||||||
// Reserve enough space for the framing prefix plus a typical header/payload pair so the
|
|
||||||
// common encode path avoids early growth reallocations inside `to_bytes_in`.
|
|
||||||
let mut frame = FrameBytes::with_capacity(header_start + 256);
|
|
||||||
frame.resize(header_start, 0);
|
|
||||||
frame = to_bytes_in::<_, Error>(header, frame).map_err(FrameError::Serialize)?;
|
|
||||||
let header_len =
|
|
||||||
u32::try_from(frame.len() - header_start).map_err(|_| FrameError::LengthOverflow)?;
|
|
||||||
|
|
||||||
let payload_start = align_up(frame.len(), SECTION_ALIGN);
|
|
||||||
frame.resize(payload_start, 0);
|
|
||||||
frame = to_bytes_in::<_, Error>(payload, frame).map_err(FrameError::Serialize)?;
|
|
||||||
let payload_len =
|
|
||||||
u32::try_from(frame.len() - payload_start).map_err(|_| FrameError::LengthOverflow)?;
|
|
||||||
|
|
||||||
frame[0..4].copy_from_slice(&header_len.to_be_bytes());
|
|
||||||
frame[4..8].copy_from_slice(&payload_len.to_be_bytes());
|
|
||||||
Ok(frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes one aligned two-section frame.
|
|
||||||
///
|
|
||||||
/// This rejects trailing bytes instead of silently ignoring them, so callers can treat one byte
|
|
||||||
/// slice as exactly one protocol frame.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
|
|
||||||
/// let frame = encode_packet(
|
|
||||||
/// &PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: Some("service".into()),
|
|
||||||
/// hook_id: None,
|
|
||||||
/// },
|
|
||||||
/// &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1, 2, 3],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// },
|
|
||||||
/// )?;
|
|
||||||
/// let parsed = decode_frame(&frame)?;
|
|
||||||
/// assert_eq!(parsed.packet_type(), PacketType::Call);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
|
|
||||||
let (header_bytes, payload_bytes) = split_frame_sections(bytes)?;
|
|
||||||
let header = deserialize_section::<ArchivedPacketHeader, PacketHeader>(
|
|
||||||
header_bytes,
|
|
||||||
FrameError::InvalidHeader,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(ParsedFrame {
|
|
||||||
header,
|
|
||||||
payload_bytes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserializes one archived byte section.
|
|
||||||
///
|
|
||||||
/// Payload bytes normally come from [`decode_frame`] or one of [`ParsedFrame`]`'s`
|
|
||||||
/// `deserialize_*` helpers. This function remains public for callers that archive nested
|
|
||||||
/// application payloads inside protocol `data` fields.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
/// use unshell::protocol::deserialize_archived_bytes;
|
|
||||||
///
|
|
||||||
/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)]
|
|
||||||
/// struct Example {
|
|
||||||
/// value: u32,
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&Example { value: 7 }).unwrap();
|
|
||||||
/// let decoded = deserialize_archived_bytes::<<Example as Archive>::Archived, Example>(&bytes)?;
|
|
||||||
/// assert_eq!(decoded, Example { value: 7 });
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError>
|
|
||||||
where
|
|
||||||
A: rkyv::Portable
|
|
||||||
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
|
|
||||||
T: rkyv::Archive,
|
|
||||||
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
|
|
||||||
{
|
|
||||||
deserialize_section::<A, T>(bytes, FrameError::InvalidPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u32(bytes: &[u8], start: usize) -> Result<u32, FrameError> {
|
|
||||||
let end = start + 4;
|
|
||||||
Ok(u32::from_be_bytes(
|
|
||||||
bytes
|
|
||||||
.get(start..end)
|
|
||||||
.ok_or(FrameError::Truncated)?
|
|
||||||
.try_into()
|
|
||||||
.expect("slice width checked"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_frame_sections(bytes: &[u8]) -> Result<(&[u8], &[u8]), FrameError> {
|
|
||||||
if bytes.len() < 8 {
|
|
||||||
return Err(FrameError::Truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
let header_len = read_u32(bytes, 0)? as usize;
|
|
||||||
let payload_len = read_u32(bytes, 4)? as usize;
|
|
||||||
let header_start = align_up(8usize, SECTION_ALIGN);
|
|
||||||
let header_end = header_start + header_len;
|
|
||||||
if header_end > bytes.len() {
|
|
||||||
return Err(FrameError::Truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload_start = align_up(header_end, SECTION_ALIGN);
|
|
||||||
let payload_end = payload_start + payload_len;
|
|
||||||
if payload_end != bytes.len() {
|
|
||||||
// Framed packets do not permit trailing bytes. Treating the slice as exactly one frame
|
|
||||||
// keeps stream framing bugs visible instead of silently accepting concatenated payloads.
|
|
||||||
return Err(FrameError::Truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
bytes
|
|
||||||
.get(header_start..header_end)
|
|
||||||
.ok_or(FrameError::Truncated)?,
|
|
||||||
bytes
|
|
||||||
.get(payload_start..payload_end)
|
|
||||||
.ok_or(FrameError::Truncated)?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn align_up(offset: usize, alignment: usize) -> usize {
|
|
||||||
let mask = alignment - 1;
|
|
||||||
(offset + mask) & !mask
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_section<A, T>(
|
|
||||||
bytes: &[u8],
|
|
||||||
invalid: fn(Error) -> FrameError,
|
|
||||||
) -> Result<T, FrameError>
|
|
||||||
where
|
|
||||||
A: rkyv::Portable
|
|
||||||
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
|
|
||||||
T: rkyv::Archive,
|
|
||||||
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
|
|
||||||
{
|
|
||||||
if is_aligned_for::<A>(bytes) {
|
|
||||||
let archived = access::<A, Error>(bytes).map_err(invalid)?;
|
|
||||||
return deserialize::<T, Error>(archived).map_err(invalid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived types may require stronger alignment than a borrowed byte slice can guarantee.
|
|
||||||
// Copy into an aligned buffer so callers can still decode valid frames from arbitrary input
|
|
||||||
// sources instead of rejecting them purely for allocation layout reasons.
|
|
||||||
let mut aligned: FrameBytes = FrameBytes::with_capacity(bytes.len());
|
|
||||||
aligned.extend_from_slice(bytes);
|
|
||||||
let archived = access::<A, Error>(&aligned).map_err(invalid)?;
|
|
||||||
deserialize::<T, Error>(archived).map_err(invalid)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_aligned_for<A>(bytes: &[u8]) -> bool {
|
|
||||||
let alignment = mem::align_of::<A>();
|
|
||||||
alignment <= 1 || (bytes.as_ptr() as usize).is_multiple_of(alignment)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
//! Required introspection payloads for discovery.
|
|
||||||
//!
|
|
||||||
//! These types define the reserved discovery subsystem of the protocol. Endpoints use the
|
|
||||||
//! reserved empty-string procedure id to request either endpoint-wide discovery or one leaf's
|
|
||||||
//! exact procedure inventory.
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//! ```rust
|
|
||||||
//! use unshell::protocol::{EndpointIntrospection, INTROSPECTION_PROCEDURE_ID};
|
|
||||||
//! let payload = EndpointIntrospection {
|
|
||||||
//! sub_endpoints: vec!["worker".into()],
|
|
||||||
//! leaves: vec![],
|
|
||||||
//! };
|
|
||||||
//! assert_eq!(INTROSPECTION_PROCEDURE_ID, "");
|
|
||||||
//! assert_eq!(payload.sub_endpoints[0], "worker");
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use alloc::{string::String, vec::Vec};
|
|
||||||
use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Reserved procedure id for protocol introspection.
|
|
||||||
///
|
|
||||||
/// The protocol uses the empty string here so discovery traffic stays outside the normal
|
|
||||||
/// application procedure namespace. [`crate::protocol::validate_procedure_id`] reserves that
|
|
||||||
/// value exclusively for introspection.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::INTROSPECTION_PROCEDURE_ID;
|
|
||||||
/// assert!(INTROSPECTION_PROCEDURE_ID.is_empty());
|
|
||||||
/// ```
|
|
||||||
pub const INTROSPECTION_PROCEDURE_ID: &str = "";
|
|
||||||
|
|
||||||
/// Endpoint-wide introspection payload.
|
|
||||||
///
|
|
||||||
/// This is returned when discovery targets an endpoint path without selecting one specific leaf.
|
|
||||||
/// It exists so clients can enumerate direct child endpoints and the leaves hosted locally.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::EndpointIntrospection;
|
|
||||||
/// let payload = EndpointIntrospection {
|
|
||||||
/// sub_endpoints: vec!["worker".into()],
|
|
||||||
/// leaves: vec![],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(payload.sub_endpoints.len(), 1);
|
|
||||||
/// ```
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct EndpointIntrospection {
|
|
||||||
/// Direct child endpoint segment names hosted immediately below this endpoint.
|
|
||||||
pub sub_endpoints: Vec<String>,
|
|
||||||
/// Leaf summaries hosted directly at this endpoint.
|
|
||||||
pub leaves: Vec<LeafIntrospectionSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared per-leaf discovery record.
|
|
||||||
///
|
|
||||||
/// This compact shape exists so endpoint-wide discovery can advertise each hosted leaf without
|
|
||||||
/// sending the full endpoint envelope again.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::LeafIntrospectionSummary;
|
|
||||||
/// let summary = LeafIntrospectionSummary {
|
|
||||||
/// leaf_name: "org.example.v1.echo".into(),
|
|
||||||
/// procedures: vec!["org.example.v1.echo.invoke".into()],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(summary.procedures.len(), 1);
|
|
||||||
/// ```
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LeafIntrospectionSummary {
|
|
||||||
/// Canonical dotted leaf identifier.
|
|
||||||
pub leaf_name: String,
|
|
||||||
/// Exhaustive canonical procedure ids currently exposed by the leaf.
|
|
||||||
pub procedures: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leaf-specific introspection payload.
|
|
||||||
///
|
|
||||||
/// This duplicates [`LeafIntrospectionSummary`] intentionally because the leaf-only response is
|
|
||||||
/// a distinct wire payload from the endpoint-wide discovery response.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::LeafIntrospection;
|
|
||||||
/// let payload = LeafIntrospection {
|
|
||||||
/// leaf_name: "org.example.v1.echo".into(),
|
|
||||||
/// procedures: vec!["org.example.v1.echo.invoke".into()],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(payload.leaf_name, "org.example.v1.echo");
|
|
||||||
/// ```
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LeafIntrospection {
|
|
||||||
/// Canonical dotted leaf identifier.
|
|
||||||
pub leaf_name: String,
|
|
||||||
/// Exhaustive canonical procedure ids currently exposed by the leaf.
|
|
||||||
pub procedures: Vec<String>,
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
//! Canonical UnShell protocol surface.
|
|
||||||
//!
|
|
||||||
//! This module is the stable facade for wire-level protocol types, framing, and
|
|
||||||
//! stateless validation helpers. Callers normally:
|
|
||||||
//! - build one [`PacketHeader`] plus payload type from this module,
|
|
||||||
//! - encode it with [`encode_packet`],
|
|
||||||
//! - decode inbound bytes with [`decode_frame`], and
|
|
||||||
//! - validate message/header shape with [`validate_header`], [`validate_call`], and
|
|
||||||
//! [`validate_procedure_id`].
|
|
||||||
//!
|
|
||||||
//! The concrete wire structs live in the private `types` module and are re-exported here so the
|
|
||||||
//! public API stays flat while internal archived-type details remain hidden.
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//! ```rust
|
|
||||||
//! use unshell::protocol::{
|
|
||||||
//! CallMessage, PacketHeader, PacketType, decode_frame, encode_packet, validate_call,
|
|
||||||
//! validate_header,
|
|
||||||
//! };
|
|
||||||
//!
|
|
||||||
//! let header = PacketHeader {
|
|
||||||
//! packet_type: PacketType::Call,
|
|
||||||
//! src_path: vec!["root".into()],
|
|
||||||
//! dst_path: vec!["root".into(), "worker".into()],
|
|
||||||
//! dst_leaf: Some("service".into()),
|
|
||||||
//! hook_id: None,
|
|
||||||
//! };
|
|
||||||
//! let call = CallMessage {
|
|
||||||
//! procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
//! data: vec![1, 2, 3],
|
|
||||||
//! response_hook: None,
|
|
||||||
//! };
|
|
||||||
//!
|
|
||||||
//! validate_header(&header).unwrap();
|
|
||||||
//! validate_call(&header, &call).unwrap();
|
|
||||||
//! let frame = encode_packet(&header, &call)?;
|
|
||||||
//! let parsed = decode_frame(&frame)?;
|
|
||||||
//! let decoded = parsed.deserialize_call()?;
|
|
||||||
//! assert_eq!(decoded.procedure_id, call.procedure_id);
|
|
||||||
//! # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
pub mod codec;
|
|
||||||
pub mod introspection;
|
|
||||||
pub mod tree;
|
|
||||||
mod types;
|
|
||||||
pub mod validation;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
pub use codec::{
|
|
||||||
FrameBytes, FrameError, ParsedFrame, SECTION_ALIGN, decode_frame, deserialize_archived_bytes,
|
|
||||||
encode_packet,
|
|
||||||
};
|
|
||||||
pub use introspection::{
|
|
||||||
EndpointIntrospection, INTROSPECTION_PROCEDURE_ID, LeafIntrospection, LeafIntrospectionSummary,
|
|
||||||
};
|
|
||||||
pub use types::{
|
|
||||||
CallMessage, DataMessage, FaultMessage, HookTarget, PacketHeader, PacketType, ProtocolFault,
|
|
||||||
};
|
|
||||||
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec};
|
|
||||||
use core::convert::Infallible;
|
|
||||||
|
|
||||||
use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::protocol::tree::{
|
|
||||||
Call, CallLeaf, ChildRoute, EndpointOutcome, Ingress, LeafRuntime, ProtocolEndpoint,
|
|
||||||
decode_call_input, encode_call_reply,
|
|
||||||
};
|
|
||||||
use crate::protocol::{PacketType, decode_frame};
|
|
||||||
use crate::{leaf, procedures};
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EchoLeaf {
|
|
||||||
prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.echo", endpoint_struct = EchoLeaf, procedures = ["echo"])]
|
|
||||||
struct Echo;
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct EchoRequest {
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct EchoResponse {
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[procedures(error = Infallible)]
|
|
||||||
impl EchoLeaf {
|
|
||||||
#[call]
|
|
||||||
fn echo(&mut self, request: Call<EchoRequest>) -> EchoResponse {
|
|
||||||
EchoResponse {
|
|
||||||
text: format!("{}{}", self.prefix, request.input.text),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallLeaf for EchoLeaf {
|
|
||||||
type Error = Infallible;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaf_runtime_dispatches_generated_call_procedure() {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![EchoLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime = LeafRuntime::new(
|
|
||||||
endpoint,
|
|
||||||
EchoLeaf {
|
|
||||||
prefix: String::from("echo: "),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut controller = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute {
|
|
||||||
path: path(&["agent"]),
|
|
||||||
registered: true,
|
|
||||||
}],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = controller.allocate_hook_id();
|
|
||||||
let controller_outcome = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(EchoLeaf::protocol_leaf_name()),
|
|
||||||
EchoLeaf::protocol_procedure_id("echo").expect("generated suffix should resolve"),
|
|
||||||
Some(hook_id),
|
|
||||||
encode_call_reply(&EchoRequest {
|
|
||||||
text: String::from("hello"),
|
|
||||||
})
|
|
||||||
.expect("request should encode"),
|
|
||||||
)
|
|
||||||
.expect("call should encode");
|
|
||||||
let EndpointOutcome::Forward { frame, .. } = controller_outcome else {
|
|
||||||
panic!("controller should forward call to child");
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, frame)
|
|
||||||
.expect("runtime should handle call");
|
|
||||||
let [response_frame] = outcome.frames.as_slice() else {
|
|
||||||
panic!("expected one response frame");
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed = decode_frame(response_frame.as_slice()).expect("response frame should decode");
|
|
||||||
assert_eq!(parsed.packet_type(), PacketType::Data);
|
|
||||||
let response = decode_call_input::<EchoResponse>(
|
|
||||||
parsed
|
|
||||||
.deserialize_data()
|
|
||||||
.expect("data payload should deserialize")
|
|
||||||
.data
|
|
||||||
.as_slice(),
|
|
||||||
)
|
|
||||||
.expect("typed response should decode");
|
|
||||||
assert_eq!(response.text, "echo: hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TopologyLeaf;
|
|
||||||
|
|
||||||
#[leaf(
|
|
||||||
id = "org.example.v1.topology",
|
|
||||||
endpoint_struct = TopologyLeaf,
|
|
||||||
procedures = ["add_child", "remove_child", "connections"]
|
|
||||||
)]
|
|
||||||
struct Topology;
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct ChildRequest {
|
|
||||||
child_path: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct ConnectionsReply {
|
|
||||||
parent: Option<Vec<String>>,
|
|
||||||
children: Vec<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[procedures(error = Infallible)]
|
|
||||||
impl TopologyLeaf {
|
|
||||||
#[call]
|
|
||||||
fn add_child(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
request: ChildRequest,
|
|
||||||
) -> ConnectionsReply {
|
|
||||||
endpoint
|
|
||||||
.upsert_child_route(ChildRoute::registered(request.child_path))
|
|
||||||
.expect("topology mutation should satisfy direct-child invariants");
|
|
||||||
ConnectionsReply {
|
|
||||||
parent: endpoint.parent_path().map(<[String]>::to_vec),
|
|
||||||
children: endpoint
|
|
||||||
.child_routes()
|
|
||||||
.iter()
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[call]
|
|
||||||
fn remove_child(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
request: ChildRequest,
|
|
||||||
) -> ConnectionsReply {
|
|
||||||
endpoint.remove_child_route(&request.child_path);
|
|
||||||
ConnectionsReply {
|
|
||||||
parent: endpoint.parent_path().map(<[String]>::to_vec),
|
|
||||||
children: endpoint
|
|
||||||
.child_routes()
|
|
||||||
.iter()
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[call]
|
|
||||||
fn connections(&mut self, endpoint: &ProtocolEndpoint) -> ConnectionsReply {
|
|
||||||
ConnectionsReply {
|
|
||||||
parent: endpoint.parent_path().map(<[String]>::to_vec),
|
|
||||||
children: endpoint
|
|
||||||
.child_routes()
|
|
||||||
.iter()
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallLeaf for TopologyLeaf {
|
|
||||||
type Error = Infallible;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_call_procedure_can_query_and_mutate_endpoint_topology() {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![TopologyLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime = LeafRuntime::new(endpoint, TopologyLeaf);
|
|
||||||
|
|
||||||
let mut controller = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["agent"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let add_hook = controller.allocate_hook_id();
|
|
||||||
let add_child = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(TopologyLeaf::protocol_leaf_name()),
|
|
||||||
TopologyLeaf::protocol_procedure_id("add_child").expect("suffix should resolve"),
|
|
||||||
Some(add_hook),
|
|
||||||
encode_call_reply(&ChildRequest {
|
|
||||||
child_path: path(&["agent", "child"]),
|
|
||||||
})
|
|
||||||
.expect("request should encode"),
|
|
||||||
)
|
|
||||||
.expect("call should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: add_child_frame,
|
|
||||||
..
|
|
||||||
} = add_child
|
|
||||||
else {
|
|
||||||
panic!("controller should forward add-child call");
|
|
||||||
};
|
|
||||||
let add_outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, add_child_frame)
|
|
||||||
.expect("runtime should mutate topology");
|
|
||||||
let [response] = add_outcome.frames.as_slice() else {
|
|
||||||
panic!("expected add-child response frame");
|
|
||||||
};
|
|
||||||
let parsed = decode_frame(response).expect("response should decode");
|
|
||||||
let reply = decode_call_input::<ConnectionsReply>(
|
|
||||||
parsed
|
|
||||||
.deserialize_data()
|
|
||||||
.expect("reply data should decode")
|
|
||||||
.data
|
|
||||||
.as_slice(),
|
|
||||||
)
|
|
||||||
.expect("typed reply should decode");
|
|
||||||
assert_eq!(reply.parent, Some(Vec::new()));
|
|
||||||
assert_eq!(reply.children, vec![path(&["agent", "child"])]);
|
|
||||||
assert_eq!(runtime.endpoint().child_routes().len(), 1);
|
|
||||||
|
|
||||||
let list_hook = controller.allocate_hook_id();
|
|
||||||
let list = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(TopologyLeaf::protocol_leaf_name()),
|
|
||||||
TopologyLeaf::protocol_procedure_id("connections").expect("suffix should resolve"),
|
|
||||||
Some(list_hook),
|
|
||||||
encode_call_reply(&()).expect("unit request should encode"),
|
|
||||||
)
|
|
||||||
.expect("list call should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: list_frame, ..
|
|
||||||
} = list
|
|
||||||
else {
|
|
||||||
panic!("controller should forward connections call");
|
|
||||||
};
|
|
||||||
let list_outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, list_frame)
|
|
||||||
.expect("runtime should return topology snapshot");
|
|
||||||
let [list_response] = list_outcome.frames.as_slice() else {
|
|
||||||
panic!("expected connections response frame");
|
|
||||||
};
|
|
||||||
let list_reply = decode_call_input::<ConnectionsReply>(
|
|
||||||
decode_frame(list_response)
|
|
||||||
.expect("response should decode")
|
|
||||||
.deserialize_data()
|
|
||||||
.expect("data should deserialize")
|
|
||||||
.data
|
|
||||||
.as_slice(),
|
|
||||||
)
|
|
||||||
.expect("typed reply should decode");
|
|
||||||
assert_eq!(list_reply.children, vec![path(&["agent", "child"])]);
|
|
||||||
|
|
||||||
let remove_hook = controller.allocate_hook_id();
|
|
||||||
let remove = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(TopologyLeaf::protocol_leaf_name()),
|
|
||||||
TopologyLeaf::protocol_procedure_id("remove_child").expect("suffix should resolve"),
|
|
||||||
Some(remove_hook),
|
|
||||||
encode_call_reply(&ChildRequest {
|
|
||||||
child_path: path(&["agent", "child"]),
|
|
||||||
})
|
|
||||||
.expect("request should encode"),
|
|
||||||
)
|
|
||||||
.expect("remove call should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: remove_frame,
|
|
||||||
..
|
|
||||||
} = remove
|
|
||||||
else {
|
|
||||||
panic!("controller should forward remove-child call");
|
|
||||||
};
|
|
||||||
runtime
|
|
||||||
.receive(&Ingress::Parent, remove_frame)
|
|
||||||
.expect("runtime should prune topology");
|
|
||||||
assert!(runtime.endpoint().child_routes().is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
use alloc::{string::String, vec};
|
|
||||||
|
|
||||||
use crate::leaf;
|
|
||||||
use crate::protocol::tree::{LeafBinding, LeafDeclaration, ProcedureMetadata, ProtocolLeaf};
|
|
||||||
|
|
||||||
struct EndpointHost;
|
|
||||||
struct Open;
|
|
||||||
struct Reset;
|
|
||||||
|
|
||||||
impl ProcedureMetadata for Open {
|
|
||||||
type Leaf = EndpointHost;
|
|
||||||
const PROCEDURE_SUFFIX: &'static str = "open";
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureMetadata for Reset {
|
|
||||||
type Leaf = EndpointHost;
|
|
||||||
const PROCEDURE_SUFFIX: &'static str = "reset";
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.demo", procedures = [Open, Reset], endpoint_struct = EndpointHost)]
|
|
||||||
struct Demo;
|
|
||||||
|
|
||||||
struct EndpointHalf;
|
|
||||||
struct TuiHalf;
|
|
||||||
struct Connect;
|
|
||||||
|
|
||||||
impl ProcedureMetadata for Connect {
|
|
||||||
type Leaf = EndpointHalf;
|
|
||||||
const PROCEDURE_SUFFIX: &'static str = "connect";
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(
|
|
||||||
name = "chat",
|
|
||||||
org = "org",
|
|
||||||
product = "example",
|
|
||||||
version = "v2",
|
|
||||||
procedures = [Connect],
|
|
||||||
endpoint_struct = EndpointHalf,
|
|
||||||
tui_struct = TuiHalf,
|
|
||||||
)]
|
|
||||||
struct Chat;
|
|
||||||
|
|
||||||
struct TuiOnly;
|
|
||||||
struct Tail;
|
|
||||||
|
|
||||||
impl ProcedureMetadata for Tail {
|
|
||||||
type Leaf = TuiOnly;
|
|
||||||
const PROCEDURE_SUFFIX: &'static str = "tail";
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.transcript", procedures = [Tail], tui_struct = TuiOnly)]
|
|
||||||
struct Transcript;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaf_declaration_generates_endpoint_host_metadata() {
|
|
||||||
assert_eq!(EndpointHost::protocol_leaf_name(), "org.example.v1.demo");
|
|
||||||
assert_eq!(
|
|
||||||
EndpointHost::protocol_leaf_spec().procedures,
|
|
||||||
vec![
|
|
||||||
String::from("org.example.v1.demo.open"),
|
|
||||||
String::from("org.example.v1.demo.reset"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
<EndpointHost as LeafBinding>::Declaration::leaf_name(),
|
|
||||||
"org.example.v1.demo"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaf_declaration_shares_metadata_between_endpoint_and_tui_hosts() {
|
|
||||||
assert_eq!(
|
|
||||||
EndpointHalf::protocol_leaf_name(),
|
|
||||||
TuiHalf::protocol_leaf_name()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
EndpointHalf::protocol_leaf_spec().procedures,
|
|
||||||
TuiHalf::protocol_leaf_spec().procedures
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
<EndpointHalf as LeafBinding>::Declaration::procedure_id("connect"),
|
|
||||||
Some(String::from("org.example.v2.chat.connect"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaf_declaration_supports_tui_only_hosts() {
|
|
||||||
assert_eq!(TuiOnly::protocol_leaf_name(), "org.example.v1.transcript");
|
|
||||||
assert_eq!(
|
|
||||||
<TuiOnly as LeafDeclaration>::procedure_id("tail"),
|
|
||||||
Some(String::from("org.example.v1.transcript.tail"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mod call;
|
|
||||||
mod leaf_decl;
|
|
||||||
mod procedure;
|
|
||||||
mod protocol;
|
|
||||||
mod tree;
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
use alloc::{borrow::ToOwned, collections::BTreeMap, format, string::String, vec, vec::Vec};
|
|
||||||
use core::convert::Infallible;
|
|
||||||
|
|
||||||
use crate::protocol::tree::{
|
|
||||||
Call, ChildRoute, Endpoint, EndpointOutcome, HookKey, Ingress, OutgoingData, Procedure,
|
|
||||||
ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, encode_call_reply,
|
|
||||||
};
|
|
||||||
use crate::protocol::{PacketType, decode_frame};
|
|
||||||
use crate::{Procedure, leaf};
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct StreamLeaf {
|
|
||||||
sessions: BTreeMap<HookKey, ProcedureOpen>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.stream", procedures = [ProcedureOpen], endpoint_struct = StreamLeaf)]
|
|
||||||
struct Stream;
|
|
||||||
|
|
||||||
impl ProcedureStore<ProcedureOpen> for StreamLeaf {
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
|
|
||||||
&mut self.sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
|
|
||||||
#[procedure(leaf = StreamLeaf, name = "open")]
|
|
||||||
struct ProcedureOpen {
|
|
||||||
prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Procedure<StreamLeaf> for ProcedureOpen {
|
|
||||||
type Error = Infallible;
|
|
||||||
type Input = String;
|
|
||||||
|
|
||||||
fn open(_leaf: &mut StreamLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self { prefix: call.input })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_data(
|
|
||||||
_leaf: &mut StreamLeaf,
|
|
||||||
session: &mut Self,
|
|
||||||
data: crate::protocol::tree::IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
Ok(ProcedureEffect {
|
|
||||||
outgoing: vec![OutgoingData {
|
|
||||||
dst_path: data.hook_key.return_path,
|
|
||||||
hook_id: data.hook_key.hook_id,
|
|
||||||
procedure_id: ProcedureOpen::protocol_procedure_id(),
|
|
||||||
data: format!(
|
|
||||||
"{}{}",
|
|
||||||
session.prefix,
|
|
||||||
String::from_utf8_lossy(&data.message.data)
|
|
||||||
)
|
|
||||||
.into_bytes(),
|
|
||||||
end_hook: data.message.end_hook,
|
|
||||||
}],
|
|
||||||
close_session: data.message.end_hook,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn procedure_runtime_routes_data_to_stored_session() {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![StreamLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime =
|
|
||||||
ProcedureRuntime::<StreamLeaf, ProcedureOpen>::new(endpoint, StreamLeaf::default());
|
|
||||||
|
|
||||||
let mut controller = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute {
|
|
||||||
path: path(&["agent"]),
|
|
||||||
registered: true,
|
|
||||||
}],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = controller.allocate_hook_id();
|
|
||||||
let open = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(StreamLeaf::protocol_leaf_name()),
|
|
||||||
ProcedureOpen::protocol_procedure_id(),
|
|
||||||
Some(hook_id),
|
|
||||||
encode_call_reply(&String::from("prefix:")).expect("procedure input should encode"),
|
|
||||||
)
|
|
||||||
.expect("open call should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: open_frame, ..
|
|
||||||
} = open
|
|
||||||
else {
|
|
||||||
panic!("controller should forward opening call");
|
|
||||||
};
|
|
||||||
runtime
|
|
||||||
.receive(&Ingress::Parent, open_frame)
|
|
||||||
.expect("runtime should open a session");
|
|
||||||
|
|
||||||
let data = controller
|
|
||||||
.send_data(
|
|
||||||
path(&["agent"]),
|
|
||||||
hook_id,
|
|
||||||
ProcedureOpen::protocol_procedure_id(),
|
|
||||||
b"hello".to_vec(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("data should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: data_frame, ..
|
|
||||||
} = data
|
|
||||||
else {
|
|
||||||
panic!("controller should forward data frame");
|
|
||||||
};
|
|
||||||
let outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, data_frame)
|
|
||||||
.expect("runtime should route data to session");
|
|
||||||
let [response_frame] = outcome.frames.as_slice() else {
|
|
||||||
panic!("expected one response frame");
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed = decode_frame(response_frame.as_slice()).expect("response frame should decode");
|
|
||||||
assert_eq!(parsed.packet_type(), PacketType::Data);
|
|
||||||
let message = parsed.deserialize_data().expect("data should deserialize");
|
|
||||||
assert!(message.end_hook);
|
|
||||||
assert_eq!(String::from_utf8_lossy(&message.data), "prefix:hello");
|
|
||||||
|
|
||||||
let forwarded = controller
|
|
||||||
.receive(&Ingress::Child(path(&["agent"])), response_frame.clone())
|
|
||||||
.expect("controller should receive session response");
|
|
||||||
assert!(matches!(forwarded, EndpointOutcome::Local(_)));
|
|
||||||
assert!(runtime.leaf_mut().procedure_sessions().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct DuplexLeaf {
|
|
||||||
sessions: BTreeMap<HookKey, DuplexProcedure>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[leaf(id = "org.example.v1.duplex", procedures = [DuplexProcedure], endpoint_struct = DuplexLeaf)]
|
|
||||||
struct Duplex;
|
|
||||||
|
|
||||||
impl ProcedureStore<DuplexProcedure> for DuplexLeaf {
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, DuplexProcedure> {
|
|
||||||
&mut self.sessions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
|
|
||||||
#[procedure(leaf = DuplexLeaf, name = "open")]
|
|
||||||
struct DuplexProcedure {
|
|
||||||
saw_peer_close: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Procedure<DuplexLeaf> for DuplexProcedure {
|
|
||||||
type Error = Infallible;
|
|
||||||
type Input = ();
|
|
||||||
|
|
||||||
fn open(_leaf: &mut DuplexLeaf, _call: Call<Self::Input>) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
saw_peer_close: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_data(
|
|
||||||
_leaf: &mut DuplexLeaf,
|
|
||||||
session: &mut Self,
|
|
||||||
data: crate::protocol::tree::IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
if data.message.data == b"local-end" {
|
|
||||||
return Ok(ProcedureEffect::outgoing(vec![OutgoingData {
|
|
||||||
dst_path: data.hook_key.return_path,
|
|
||||||
hook_id: data.hook_key.hook_id,
|
|
||||||
procedure_id: DuplexProcedure::protocol_procedure_id(),
|
|
||||||
data: Vec::new(),
|
|
||||||
end_hook: true,
|
|
||||||
}]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.message.end_hook {
|
|
||||||
session.saw_peer_close = true;
|
|
||||||
return Ok(ProcedureEffect::close(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ProcedureEffect::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn procedure_runtime_keeps_session_after_local_end_until_explicit_close() {
|
|
||||||
let endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
Vec::new(),
|
|
||||||
vec![DuplexLeaf::protocol_leaf_spec()],
|
|
||||||
);
|
|
||||||
let mut runtime =
|
|
||||||
ProcedureRuntime::<DuplexLeaf, DuplexProcedure>::new(endpoint, DuplexLeaf::default());
|
|
||||||
|
|
||||||
let mut controller = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute {
|
|
||||||
path: path(&["agent"]),
|
|
||||||
registered: true,
|
|
||||||
}],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = controller.allocate_hook_id();
|
|
||||||
let open = controller
|
|
||||||
.send_call(
|
|
||||||
path(&["agent"]),
|
|
||||||
Some(DuplexLeaf::protocol_leaf_name()),
|
|
||||||
DuplexProcedure::protocol_procedure_id(),
|
|
||||||
Some(hook_id),
|
|
||||||
encode_call_reply(&()).expect("unit call should encode"),
|
|
||||||
)
|
|
||||||
.expect("open call should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: open_frame, ..
|
|
||||||
} = open
|
|
||||||
else {
|
|
||||||
panic!("controller should forward opening call");
|
|
||||||
};
|
|
||||||
runtime
|
|
||||||
.receive(&Ingress::Parent, open_frame)
|
|
||||||
.expect("runtime should open duplex session");
|
|
||||||
|
|
||||||
let local_end = controller
|
|
||||||
.send_data(
|
|
||||||
path(&["agent"]),
|
|
||||||
hook_id,
|
|
||||||
DuplexProcedure::protocol_procedure_id(),
|
|
||||||
b"local-end".to_vec(),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("local end trigger should encode");
|
|
||||||
let EndpointOutcome::Forward {
|
|
||||||
frame: local_end_frame,
|
|
||||||
..
|
|
||||||
} = local_end
|
|
||||||
else {
|
|
||||||
panic!("controller should forward local end trigger");
|
|
||||||
};
|
|
||||||
let outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, local_end_frame)
|
|
||||||
.expect("runtime should emit a local end packet");
|
|
||||||
assert_eq!(outcome.frames.len(), 1);
|
|
||||||
assert_eq!(runtime.leaf_mut().procedure_sessions().len(), 1);
|
|
||||||
|
|
||||||
let peer_end = encode_call_reply(&()).expect("unit value is just a placeholder");
|
|
||||||
let peer_end = crate::protocol::encode_packet(
|
|
||||||
&crate::protocol::PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: Vec::new(),
|
|
||||||
dst_path: path(&["agent"]),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&crate::protocol::DataMessage {
|
|
||||||
procedure_id: DuplexProcedure::protocol_procedure_id(),
|
|
||||||
data: peer_end,
|
|
||||||
end_hook: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("peer end frame should encode");
|
|
||||||
let peer_end_outcome = runtime
|
|
||||||
.receive(&Ingress::Parent, peer_end)
|
|
||||||
.expect("runtime should accept peer end after local end");
|
|
||||||
assert!(peer_end_outcome.frames.is_empty());
|
|
||||||
assert!(runtime.leaf_mut().procedure_sessions().is_empty());
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
CallMessage, FaultMessage, FrameError, HookTarget, PacketHeader, PacketType, ProtocolFault,
|
|
||||||
SECTION_ALIGN, ValidationError, decode_frame, encode_packet, validate_call, validate_header,
|
|
||||||
validate_procedure_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packet_framing_roundtrip_preserves_header_and_payload() {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["root", "caller"]),
|
|
||||||
dst_path: path(&["root", "callee"]),
|
|
||||||
dst_leaf: Some("service".to_owned()),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let call = CallMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![1, 2, 3, 4],
|
|
||||||
response_hook: Some(HookTarget {
|
|
||||||
hook_id: 7,
|
|
||||||
return_path: path(&["root", "caller"]),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let frame = encode_packet(&header, &call).expect("frame should encode");
|
|
||||||
assert_eq!(frame.as_ptr() as usize % SECTION_ALIGN, 0);
|
|
||||||
let parsed = decode_frame(&frame).expect("frame should decode");
|
|
||||||
|
|
||||||
assert_eq!(parsed.header(), &header);
|
|
||||||
assert_eq!(parsed.packet_type(), PacketType::Call);
|
|
||||||
assert_eq!(
|
|
||||||
parsed.deserialize_call().expect("call should deserialize"),
|
|
||||||
call
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn header_and_call_validation_reject_invalid_combinations() {
|
|
||||||
let invalid_header = PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["peer"]),
|
|
||||||
dst_path: path(&["host"]),
|
|
||||||
dst_leaf: Some("service".to_owned()),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
validate_header(&invalid_header),
|
|
||||||
Err(ValidationError::HeaderInvariant(
|
|
||||||
"Data and Fault packets must not carry dst_leaf"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["caller"]),
|
|
||||||
dst_path: path(&["callee"]),
|
|
||||||
dst_leaf: Some("service".to_owned()),
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let invalid_call = CallMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: Vec::new(),
|
|
||||||
response_hook: Some(HookTarget {
|
|
||||||
hook_id: 5,
|
|
||||||
return_path: path(&["elsewhere"]),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
validate_call(&header, &invalid_call),
|
|
||||||
Err(ValidationError::CallInvariant(
|
|
||||||
"response_hook.return_path must equal header.src_path"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn procedure_validation_accepts_introspection_and_non_empty_opaque_ids() {
|
|
||||||
assert_eq!(validate_procedure_id(""), Ok(()));
|
|
||||||
assert_eq!(validate_procedure_id("example.service.v01.invoke"), Ok(()));
|
|
||||||
assert_eq!(validate_procedure_id("contains spaces"), Ok(()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_frames_are_rejected() {
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Fault,
|
|
||||||
src_path: path(&["src"]),
|
|
||||||
dst_path: path(&["dst"]),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(9),
|
|
||||||
};
|
|
||||||
let message = FaultMessage {
|
|
||||||
fault: ProtocolFault::INTERNAL_ERROR,
|
|
||||||
};
|
|
||||||
|
|
||||||
let frame = encode_packet(&header, &message).expect("frame should encode");
|
|
||||||
let truncated = &frame[..frame.len() - 1];
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode_frame(truncated),
|
|
||||||
Err(FrameError::Truncated)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
|
|
||||||
|
|
||||||
use crate::protocol::tree::{
|
|
||||||
ChildRoute, DefaultRouteProvider, Endpoint, EndpointOutcome, Ingress, LeafNode, LeafSpec,
|
|
||||||
LocalEvent, ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
|
|
||||||
};
|
|
||||||
use crate::protocol::{
|
|
||||||
DataMessage, EndpointIntrospection, FaultMessage, PacketHeader, PacketType, ProtocolFault,
|
|
||||||
deserialize_archived_bytes, encode_packet,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn path(parts: &[&str]) -> Vec<String> {
|
|
||||||
parts.iter().map(|part| (*part).to_owned()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_node_paths_flatten_explicitly() {
|
|
||||||
let tree = TreeNode::Root {
|
|
||||||
children: vec![TreeNode::Endpoint {
|
|
||||||
segment: "branch".to_owned(),
|
|
||||||
leaves: vec![LeafNode {
|
|
||||||
name: "service".to_owned(),
|
|
||||||
procedures: vec!["example.service.v1.invoke".to_owned()],
|
|
||||||
}],
|
|
||||||
children: vec![TreeNode::Endpoint {
|
|
||||||
segment: "leaf".to_owned(),
|
|
||||||
leaves: Vec::new(),
|
|
||||||
children: Vec::new(),
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
tree.paths(),
|
|
||||||
vec![
|
|
||||||
Vec::<String>::new(),
|
|
||||||
path(&["branch"]),
|
|
||||||
path(&["branch", "leaf"])
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn longest_prefix_routing_prefers_most_specific_child() {
|
|
||||||
let provider = DefaultRouteProvider;
|
|
||||||
let child_paths = vec![path(&["a"]), path(&["a", "b"]), path(&["x"])];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
provider.route_destination(&Vec::new(), &child_paths, true, &path(&["a", "b", "c"])),
|
|
||||||
RouteDecision::Child(1)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
provider.route_destination(&path(&["a"]), &child_paths, true, &path(&["z"])),
|
|
||||||
RouteDecision::Parent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protocol_endpoint_introspection_returns_leaf_summary() {
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
path(&["root"]),
|
|
||||||
Some(Vec::new()),
|
|
||||||
vec![ChildRoute::registered(path(&["root", "child"]))],
|
|
||||||
vec![LeafSpec {
|
|
||||||
name: "service".to_owned(),
|
|
||||||
procedures: vec!["example.service.v1.invoke".to_owned()],
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
let hook_id = endpoint.allocate_hook_id();
|
|
||||||
let frame = endpoint
|
|
||||||
.make_call(path(&["root"]), None, "", Some(hook_id), Vec::new())
|
|
||||||
.expect("introspection call should encode");
|
|
||||||
|
|
||||||
let outcome = endpoint
|
|
||||||
.receive(&Ingress::Local, frame)
|
|
||||||
.expect("endpoint should handle introspection");
|
|
||||||
|
|
||||||
let EndpointOutcome::Local(LocalEvent::Data {
|
|
||||||
header,
|
|
||||||
message: response,
|
|
||||||
..
|
|
||||||
}) = &outcome
|
|
||||||
else {
|
|
||||||
panic!("expected local data event");
|
|
||||||
};
|
|
||||||
assert_eq!(header.packet_type, PacketType::Data);
|
|
||||||
assert_eq!(header.dst_path, path(&["root"]));
|
|
||||||
let introspection = deserialize_archived_bytes::<
|
|
||||||
crate::protocol::introspection::ArchivedEndpointIntrospection,
|
|
||||||
EndpointIntrospection,
|
|
||||||
>(&response.data)
|
|
||||||
.expect("introspection payload should deserialize");
|
|
||||||
|
|
||||||
assert!(response.end_hook);
|
|
||||||
assert_eq!(introspection.sub_endpoints, vec!["child".to_owned()]);
|
|
||||||
assert_eq!(introspection.leaves.len(), 1);
|
|
||||||
assert_eq!(introspection.leaves[0].leaf_name, "service");
|
|
||||||
assert_eq!(
|
|
||||||
introspection.leaves[0].procedures,
|
|
||||||
vec!["example.service.v1.invoke".to_owned()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_hook_peer_emits_local_fault_event() {
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![
|
|
||||||
ChildRoute::registered(path(&["server"])),
|
|
||||||
ChildRoute::registered(path(&["intruder"])),
|
|
||||||
],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = endpoint.allocate_hook_id();
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.make_call(
|
|
||||||
path(&["server"]),
|
|
||||||
None,
|
|
||||||
"example.service.v1.invoke",
|
|
||||||
Some(hook_id),
|
|
||||||
vec![1, 2, 3],
|
|
||||||
)
|
|
||||||
.expect("call should establish an active hook");
|
|
||||||
|
|
||||||
let valid_frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["server"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&DataMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![8],
|
|
||||||
end_hook: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("valid server data should encode");
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.receive(&Ingress::Child(path(&["server"])), valid_frame)
|
|
||||||
.expect("first server data should activate the hook");
|
|
||||||
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["intruder"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&DataMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![9],
|
|
||||||
end_hook: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("data frame should encode");
|
|
||||||
|
|
||||||
let outcome = endpoint
|
|
||||||
.receive(&Ingress::Child(path(&["intruder"])), frame)
|
|
||||||
.expect("invalid peer should be handled");
|
|
||||||
|
|
||||||
match &outcome {
|
|
||||||
EndpointOutcome::Local(event) => match event {
|
|
||||||
LocalEvent::Fault {
|
|
||||||
header, message, ..
|
|
||||||
} => {
|
|
||||||
assert_eq!(header.packet_type, PacketType::Fault);
|
|
||||||
assert_eq!(header.hook_id, Some(hook_id));
|
|
||||||
assert_eq!(
|
|
||||||
message,
|
|
||||||
&FaultMessage {
|
|
||||||
fault: ProtocolFault::INVALID_HOOK_PEER,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected fault event, got {other:?}"),
|
|
||||||
},
|
|
||||||
other => panic!("expected local fault event, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hook_closes_only_after_both_sides_end() {
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
Vec::new(),
|
|
||||||
None,
|
|
||||||
vec![ChildRoute::registered(path(&["server"]))],
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
let hook_id = endpoint.allocate_hook_id();
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.make_call(
|
|
||||||
path(&["server"]),
|
|
||||||
None,
|
|
||||||
"example.service.v1.invoke",
|
|
||||||
Some(hook_id),
|
|
||||||
vec![1],
|
|
||||||
)
|
|
||||||
.expect("call should establish an active hook");
|
|
||||||
|
|
||||||
let host_key = crate::protocol::tree::HookKey::new(Vec::new(), hook_id);
|
|
||||||
assert!(endpoint.hooks.pending(&host_key).is_some());
|
|
||||||
|
|
||||||
let activation_frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["server"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&DataMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![9],
|
|
||||||
end_hook: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("activation data should encode");
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.receive(&Ingress::Child(path(&["server"])), activation_frame)
|
|
||||||
.expect("first server data should activate the hook");
|
|
||||||
assert!(endpoint.hooks.active(&host_key).is_some());
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.send_data(
|
|
||||||
path(&["server"]),
|
|
||||||
hook_id,
|
|
||||||
"example.service.v1.invoke",
|
|
||||||
vec![2],
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("local end should succeed");
|
|
||||||
assert!(endpoint.hooks.active(&host_key).is_some());
|
|
||||||
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: path(&["server"]),
|
|
||||||
dst_path: Vec::new(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
},
|
|
||||||
&DataMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![3],
|
|
||||||
end_hook: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("peer final data should encode");
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.receive(&Ingress::Child(path(&["server"])), frame)
|
|
||||||
.expect("peer final data should be handled");
|
|
||||||
assert!(endpoint.hooks.active(&host_key).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_hook_fault_is_delivered_before_activation() {
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(path(&["server"]), None, Vec::new(), Vec::new());
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: path(&["client"]),
|
|
||||||
dst_path: path(&["server"]),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let call = crate::protocol::CallMessage {
|
|
||||||
procedure_id: crate::protocol::INTROSPECTION_PROCEDURE_ID.to_owned(),
|
|
||||||
data: Vec::new(),
|
|
||||||
response_hook: Some(crate::protocol::HookTarget {
|
|
||||||
hook_id: 11,
|
|
||||||
return_path: path(&["client"]),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.hooks
|
|
||||||
.insert_pending(
|
|
||||||
crate::protocol::tree::HookKey::new(path(&["client"]), 11),
|
|
||||||
crate::protocol::tree::PendingHook {
|
|
||||||
caller_src_path: path(&["client"]),
|
|
||||||
procedure_id: call.procedure_id.clone(),
|
|
||||||
local_ended: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("pending hook should insert");
|
|
||||||
|
|
||||||
let outcome = endpoint
|
|
||||||
.handle_introspection(
|
|
||||||
&header,
|
|
||||||
Some(crate::protocol::tree::HookKey::new(path(&["client"]), 11)),
|
|
||||||
)
|
|
||||||
.expect("introspection should handle pending hook");
|
|
||||||
|
|
||||||
assert!(!matches!(outcome, EndpointOutcome::Dropped));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn callee_side_end_hook_marks_local_end_before_peer_close() {
|
|
||||||
let mut endpoint = ProtocolEndpoint::new(path(&["server"]), None, Vec::new(), Vec::new());
|
|
||||||
endpoint
|
|
||||||
.add_endpoint_procedure("example.service.v1.invoke")
|
|
||||||
.expect("procedure registration should succeed");
|
|
||||||
let frame = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: Vec::new(),
|
|
||||||
dst_path: path(&["server"]),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: None,
|
|
||||||
},
|
|
||||||
&crate::protocol::CallMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: vec![1],
|
|
||||||
response_hook: Some(crate::protocol::HookTarget {
|
|
||||||
hook_id: 21,
|
|
||||||
return_path: Vec::new(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("call should encode");
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.receive(&Ingress::Parent, frame)
|
|
||||||
.expect("callee should accept call");
|
|
||||||
|
|
||||||
let key = crate::protocol::tree::HookKey::new(Vec::new(), 21);
|
|
||||||
assert!(endpoint.hooks.active(&key).is_some());
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.send_data(
|
|
||||||
Vec::new(),
|
|
||||||
21,
|
|
||||||
"example.service.v1.invoke",
|
|
||||||
Vec::new(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.expect("callee local end should succeed");
|
|
||||||
assert!(endpoint.hooks.active(&key).is_some());
|
|
||||||
|
|
||||||
let peer_final = encode_packet(
|
|
||||||
&PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: Vec::new(),
|
|
||||||
dst_path: path(&["server"]),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(21),
|
|
||||||
},
|
|
||||||
&DataMessage {
|
|
||||||
procedure_id: "example.service.v1.invoke".to_owned(),
|
|
||||||
data: Vec::new(),
|
|
||||||
end_hook: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("peer final data should encode");
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.receive(&Ingress::Parent, peer_final)
|
|
||||||
.expect("callee should accept peer close");
|
|
||||||
assert!(endpoint.hooks.active(&key).is_none());
|
|
||||||
}
|
|
||||||
@@ -1,801 +0,0 @@
|
|||||||
//! Stateful application-layer call runtime built on top of `ProtocolEndpoint`.
|
|
||||||
|
|
||||||
use alloc::{string::String, vec, vec::Vec};
|
|
||||||
use core::fmt;
|
|
||||||
|
|
||||||
use rkyv::{Archive, Serialize, rancor::Error, to_bytes, util::AlignedVec};
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
CallMessage, DataMessage, FrameBytes, FrameError, HookTarget, PacketHeader, ProtocolFault,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::endpoint::ForwardedFrame;
|
|
||||||
use super::{
|
|
||||||
Endpoint, EndpointError, HookKey, Ingress, LocalEvent, ProtocolEndpoint, ProtocolLeaf,
|
|
||||||
RouteDecision, RouterLeaf,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// One typed incoming `Call` passed to a leaf procedure.
|
|
||||||
///
|
|
||||||
/// This exists so application code can work with a decoded request type plus the protocol context
|
|
||||||
/// that matters for authorization, routing, or replies.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{Call, HookKey};
|
|
||||||
/// let call = Call {
|
|
||||||
/// input: String::from("hello"),
|
|
||||||
/// caller_path: vec!["root".into()],
|
|
||||||
/// procedure_id: "org.example.v1.echo.invoke".into(),
|
|
||||||
/// dst_leaf: Some("echo".into()),
|
|
||||||
/// response_hook: Some(HookKey::new(vec!["root".into()], 7)),
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(call.input, "hello");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Call<T> {
|
|
||||||
/// Decoded application input payload.
|
|
||||||
pub input: T,
|
|
||||||
/// Endpoint path of the caller that opened this call.
|
|
||||||
pub caller_path: Vec<String>,
|
|
||||||
/// Canonical procedure identifier chosen by the caller.
|
|
||||||
pub procedure_id: String,
|
|
||||||
/// Optional destination leaf targeted by the call.
|
|
||||||
pub dst_leaf: Option<String>,
|
|
||||||
/// Hook key declared by the caller when it expects a response.
|
|
||||||
pub response_hook: Option<HookKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One incoming local call event that already passed protocol validation.
|
|
||||||
///
|
|
||||||
/// This exists for dispatch layers that still want direct access to the raw protocol payload
|
|
||||||
/// before converting it into a typed [`Call<T>`].
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType};
|
|
||||||
/// use unshell::protocol::tree::IncomingCall;
|
|
||||||
/// let call = IncomingCall {
|
|
||||||
/// header: PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// },
|
|
||||||
/// message: CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// },
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(call.message.procedure_id, "example.invoke");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct IncomingCall {
|
|
||||||
/// Validated protocol header for the call.
|
|
||||||
pub header: PacketHeader,
|
|
||||||
/// Application payload for the call.
|
|
||||||
pub message: CallMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One incoming local data event tied to an active hook.
|
|
||||||
///
|
|
||||||
/// This exists so hook-aware leaf code receives both the payload and the resolved hook identity
|
|
||||||
/// that owns the stream.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{DataMessage, PacketHeader, PacketType};
|
|
||||||
/// use unshell::protocol::tree::{HookKey, IncomingData};
|
|
||||||
/// let data = IncomingData {
|
|
||||||
/// header: PacketHeader {
|
|
||||||
/// packet_type: PacketType::Data,
|
|
||||||
/// src_path: vec!["worker".into()],
|
|
||||||
/// dst_path: vec!["root".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: Some(7),
|
|
||||||
/// },
|
|
||||||
/// message: DataMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1],
|
|
||||||
/// end_hook: false,
|
|
||||||
/// },
|
|
||||||
/// hook_key: HookKey::new(vec!["root".into()], 7),
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(data.hook_key.hook_id, 7);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct IncomingData {
|
|
||||||
/// Validated protocol header for the data packet.
|
|
||||||
pub header: PacketHeader,
|
|
||||||
/// Hook-associated data payload.
|
|
||||||
pub message: DataMessage,
|
|
||||||
/// Resolved hook key for the active session.
|
|
||||||
pub hook_key: HookKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One incoming local fault event tied to a pending or active hook.
|
|
||||||
///
|
|
||||||
/// This exists so leaf code can observe upstream protocol termination and release any
|
|
||||||
/// application-level resources associated with the hook.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault};
|
|
||||||
/// use unshell::protocol::tree::{HookKey, IncomingFault};
|
|
||||||
/// let fault = IncomingFault {
|
|
||||||
/// header: PacketHeader {
|
|
||||||
/// packet_type: PacketType::Fault,
|
|
||||||
/// src_path: vec!["worker".into()],
|
|
||||||
/// dst_path: vec!["root".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: Some(7),
|
|
||||||
/// },
|
|
||||||
/// fault: FaultMessage { fault: ProtocolFault::INTERNAL_ERROR },
|
|
||||||
/// hook_key: HookKey::new(vec!["root".into()], 7),
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(fault.hook_key.hook_id, 7);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct IncomingFault {
|
|
||||||
/// Validated protocol header for the fault packet.
|
|
||||||
pub header: PacketHeader,
|
|
||||||
/// Fault payload emitted by the peer.
|
|
||||||
pub fault: crate::protocol::FaultMessage,
|
|
||||||
/// Hook key for the pending or active session that faulted.
|
|
||||||
pub hook_key: HookKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of one generated initial call procedure.
|
|
||||||
///
|
|
||||||
/// This exists for generated one-shot leaf procedures that either emit one reply payload or
|
|
||||||
/// intentionally complete without any returned hook traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::CallResult;
|
|
||||||
/// let reply: CallResult<String> = CallResult::Reply("hello".into());
|
|
||||||
/// assert!(matches!(reply, CallResult::Reply(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CallResult<T> {
|
|
||||||
/// Return one reply payload to the caller.
|
|
||||||
Reply(T),
|
|
||||||
/// Complete the call without any response data.
|
|
||||||
NoReply,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One hook-associated `Data` packet emitted by leaf code.
|
|
||||||
///
|
|
||||||
/// This exists as the normalized outbound unit produced by leaf code before the runtime turns it
|
|
||||||
/// into framed protocol traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::OutgoingData;
|
|
||||||
/// let packet = OutgoingData {
|
|
||||||
/// dst_path: vec!["root".into()],
|
|
||||||
/// hook_id: 7,
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![1, 2, 3],
|
|
||||||
/// end_hook: true,
|
|
||||||
/// };
|
|
||||||
/// assert!(packet.end_hook);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct OutgoingData {
|
|
||||||
/// Destination endpoint path for the hook packet.
|
|
||||||
pub dst_path: Vec<String>,
|
|
||||||
/// Hook identifier scoped to the receiving endpoint.
|
|
||||||
pub hook_id: u64,
|
|
||||||
/// Procedure identifier that owns this hook stream.
|
|
||||||
pub procedure_id: String,
|
|
||||||
/// Serialized application data to send.
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
/// Whether this packet closes the local side of the hook.
|
|
||||||
pub end_hook: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One runtime-normalized reply produced by generated call dispatch.
|
|
||||||
///
|
|
||||||
/// This exists because generated call dispatch always normalizes leaf return values into either
|
|
||||||
/// serialized reply bytes or an explicit “no reply” outcome.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::CallReply;
|
|
||||||
/// let reply = CallReply::Reply(vec![1, 2, 3]);
|
|
||||||
/// assert!(matches!(reply, CallReply::Reply(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CallReply {
|
|
||||||
/// Serialized reply bytes that should be returned upstream.
|
|
||||||
Reply(Vec<u8>),
|
|
||||||
/// Complete without emitting any reply packet.
|
|
||||||
NoReply,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error surfaced while decoding one incoming call or encoding one generated reply.
|
|
||||||
///
|
|
||||||
/// This exists so generated dispatch can keep decode, encode, and handler failures distinct while
|
|
||||||
/// still using one error channel.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{FrameError};
|
|
||||||
/// use unshell::protocol::tree::DispatchError;
|
|
||||||
/// let error: DispatchError<core::convert::Infallible> = DispatchError::Decode(FrameError::Truncated);
|
|
||||||
/// assert!(matches!(error, DispatchError::Decode(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum DispatchError<E> {
|
|
||||||
/// Failed to decode the typed call input.
|
|
||||||
Decode(FrameError),
|
|
||||||
/// Failed to encode the typed call output.
|
|
||||||
Encode(FrameError),
|
|
||||||
/// The leaf-specific call handler returned an error.
|
|
||||||
Handler(E),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> fmt::Display for DispatchError<E>
|
|
||||||
where
|
|
||||||
E: fmt::Display,
|
|
||||||
{
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Decode(error) => write!(f, "call decode failed: {error}"),
|
|
||||||
Self::Encode(error) => write!(f, "call reply encode failed: {error}"),
|
|
||||||
Self::Handler(error) => write!(f, "call handler failed: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> core::error::Error for DispatchError<E> where E: core::error::Error + 'static {}
|
|
||||||
|
|
||||||
/// Error surfaced by the stateful leaf runtime.
|
|
||||||
///
|
|
||||||
/// This exists so callers can distinguish transport/runtime failures from leaf-local business
|
|
||||||
/// logic failures.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{FrameError};
|
|
||||||
/// use unshell::protocol::tree::{DispatchError, LeafRuntimeError};
|
|
||||||
/// let error: LeafRuntimeError<core::convert::Infallible> = LeafRuntimeError::Dispatch(DispatchError::Decode(FrameError::Truncated));
|
|
||||||
/// assert!(matches!(error, LeafRuntimeError::Dispatch(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LeafRuntimeError<E> {
|
|
||||||
/// Protocol endpoint routing or framing failed.
|
|
||||||
Endpoint(EndpointError),
|
|
||||||
/// Typed call dispatch failed.
|
|
||||||
Dispatch(DispatchError<E>),
|
|
||||||
/// Leaf-local data or fault handling failed.
|
|
||||||
Leaf(E),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> fmt::Display for LeafRuntimeError<E>
|
|
||||||
where
|
|
||||||
E: fmt::Display,
|
|
||||||
{
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Endpoint(error) => write!(f, "{error}"),
|
|
||||||
Self::Dispatch(error) => write!(f, "{error}"),
|
|
||||||
Self::Leaf(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> core::error::Error for LeafRuntimeError<E> where E: core::error::Error + 'static {}
|
|
||||||
|
|
||||||
impl<E> From<EndpointError> for LeafRuntimeError<E> {
|
|
||||||
fn from(value: EndpointError) -> Self {
|
|
||||||
Self::Endpoint(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level leaf behavior layered on top of validated protocol events.
|
|
||||||
///
|
|
||||||
/// This exists for leaves that want validated call/data/fault delivery without managing endpoint
|
|
||||||
/// routing details themselves.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::CallLeaf;
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl unshell::protocol::tree::ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// impl CallLeaf for ExampleLeaf {
|
|
||||||
/// type Error = core::convert::Infallible;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait CallLeaf: ProtocolLeaf {
|
|
||||||
/// Leaf-specific error surfaced by call, data, or fault handling.
|
|
||||||
type Error;
|
|
||||||
|
|
||||||
/// Handles hook-associated inbound `Data` after protocol validation.
|
|
||||||
fn on_data(&mut self, _data: IncomingData) -> Result<Vec<OutgoingData>, Self::Error> {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observes one inbound `Fault` after protocol validation.
|
|
||||||
fn on_fault(&mut self, _fault: IncomingFault) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls the leaf for locally-generated hook traffic.
|
|
||||||
fn poll(&mut self) -> Result<Vec<OutgoingData>, Self::Error> {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stateful runtime that combines a protocol endpoint with one leaf instance.
|
|
||||||
///
|
|
||||||
/// This exists as the high-level runtime for simple one-shot call procedures plus hook data/fault
|
|
||||||
/// handling.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::LeafRuntime;
|
|
||||||
/// # struct Leaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<Leaf>>;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LeafRuntime<L> {
|
|
||||||
endpoint: ProtocolEndpoint,
|
|
||||||
leaf: L,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frames emitted by the runtime after one receive or poll step.
|
|
||||||
///
|
|
||||||
/// This exists so callers can flush emitted frames to transport while also learning whether the
|
|
||||||
/// inbound packet was intentionally dropped.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::RuntimeOutcome;
|
|
||||||
/// let outcome = RuntimeOutcome::default();
|
|
||||||
/// assert!(outcome.frames.is_empty());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct RuntimeOutcome {
|
|
||||||
/// Frames emitted while processing the step.
|
|
||||||
pub frames: Vec<FrameBytes>,
|
|
||||||
/// Whether the endpoint dropped the incoming packet.
|
|
||||||
pub dropped: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frames emitted by the runtime together with their chosen next hops.
|
|
||||||
///
|
|
||||||
/// What it is: the router-oriented variant of [`RuntimeOutcome`], preserving the
|
|
||||||
/// `RouteDecision` for every emitted frame.
|
|
||||||
///
|
|
||||||
/// Why it exists: transport-owning leaves need to know whether each frame should
|
|
||||||
/// go to the parent or to a specific child, not just the encoded bytes.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::RoutedRuntimeOutcome;
|
|
||||||
/// let outcome = RoutedRuntimeOutcome::default();
|
|
||||||
/// assert!(outcome.forwarded.is_empty());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default, Clone)]
|
|
||||||
pub struct RoutedRuntimeOutcome {
|
|
||||||
/// Forwarded frames paired with the route chosen by the endpoint runtime.
|
|
||||||
pub forwarded: Vec<ForwardedFrame>,
|
|
||||||
/// Whether the endpoint dropped the incoming packet.
|
|
||||||
pub dropped: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<L> LeafRuntime<L> {
|
|
||||||
/// Builds a runtime from one endpoint and one leaf instance.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
|
|
||||||
/// let _ = runtime;
|
|
||||||
/// ```
|
|
||||||
pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
|
|
||||||
Self { endpoint, leaf }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the underlying protocol endpoint.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
|
|
||||||
/// let _endpoint = runtime.endpoint();
|
|
||||||
/// ```
|
|
||||||
pub fn endpoint(&self) -> &ProtocolEndpoint {
|
|
||||||
&self.endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a mutable reference to the underlying endpoint.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
|
|
||||||
/// let _endpoint = runtime.endpoint_mut();
|
|
||||||
/// ```
|
|
||||||
pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
|
|
||||||
&mut self.endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the hosted leaf instance.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
|
|
||||||
/// let _leaf = runtime.leaf();
|
|
||||||
/// ```
|
|
||||||
pub fn leaf(&self) -> &L {
|
|
||||||
&self.leaf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a mutable reference to the hosted leaf instance.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
|
|
||||||
/// let _leaf = runtime.leaf_mut();
|
|
||||||
/// ```
|
|
||||||
pub fn leaf_mut(&mut self) -> &mut L {
|
|
||||||
&mut self.leaf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<L> LeafRuntime<L>
|
|
||||||
where
|
|
||||||
L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error>,
|
|
||||||
{
|
|
||||||
/// Delivers one inbound frame into the stateful leaf runtime.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// # struct ExampleLeaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
|
|
||||||
/// ```
|
|
||||||
pub fn receive(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let routed = self.receive_routed(ingress, frame)?;
|
|
||||||
Ok(RuntimeOutcome {
|
|
||||||
frames: routed
|
|
||||||
.forwarded
|
|
||||||
.into_iter()
|
|
||||||
.map(|forwarded| forwarded.frame)
|
|
||||||
.collect(),
|
|
||||||
dropped: routed.dropped,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delivers one inbound frame while preserving route decisions for emitted traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// # struct ExampleLeaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
|
|
||||||
/// ```
|
|
||||||
pub fn receive_routed(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let outcome = self.endpoint.receive(ingress, frame)?;
|
|
||||||
self.process_endpoint_outcome_routed(outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls the leaf for locally-generated hook traffic and routes any emitted frames.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// # struct ExampleLeaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
|
|
||||||
/// ```
|
|
||||||
pub fn poll(&mut self) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let routed = self.poll_routed()?;
|
|
||||||
Ok(RuntimeOutcome {
|
|
||||||
frames: routed
|
|
||||||
.forwarded
|
|
||||||
.into_iter()
|
|
||||||
.map(|forwarded| forwarded.frame)
|
|
||||||
.collect(),
|
|
||||||
dropped: routed.dropped,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls the leaf while preserving route decisions for emitted traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// # struct ExampleLeaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
|
|
||||||
/// ```
|
|
||||||
pub fn poll_routed(
|
|
||||||
&mut self,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?;
|
|
||||||
self.emit_outgoing_routed(outgoing)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_endpoint_outcome_routed(
|
|
||||||
&mut self,
|
|
||||||
outcome: crate::protocol::tree::EndpointOutcome,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
match outcome {
|
|
||||||
crate::protocol::tree::EndpointOutcome::Forward { route, frame } => {
|
|
||||||
Ok(RoutedRuntimeOutcome {
|
|
||||||
forwarded: vec![ForwardedFrame { route, frame }],
|
|
||||||
dropped: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
crate::protocol::tree::EndpointOutcome::Dropped => Ok(RoutedRuntimeOutcome {
|
|
||||||
forwarded: Vec::new(),
|
|
||||||
dropped: true,
|
|
||||||
}),
|
|
||||||
crate::protocol::tree::EndpointOutcome::Local(event) => self.process_local_event(event),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_event(
|
|
||||||
&mut self,
|
|
||||||
event: LocalEvent,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
match event {
|
|
||||||
LocalEvent::Call { header, message } => self.process_local_call(header, message),
|
|
||||||
LocalEvent::Data {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key,
|
|
||||||
} => self.process_local_data(header, message, hook_key),
|
|
||||||
LocalEvent::Fault {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key,
|
|
||||||
} => self.process_local_fault(header, message, hook_key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_call(
|
|
||||||
&mut self,
|
|
||||||
header: PacketHeader,
|
|
||||||
message: CallMessage,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let CallMessage {
|
|
||||||
procedure_id,
|
|
||||||
data,
|
|
||||||
response_hook,
|
|
||||||
} = message;
|
|
||||||
let fault_hook = response_hook.as_ref();
|
|
||||||
let incoming = IncomingCall {
|
|
||||||
header,
|
|
||||||
// Split the payload apart so the reply path can reuse the owned procedure id and
|
|
||||||
// response hook without re-decoding the incoming bytes.
|
|
||||||
message: CallMessage {
|
|
||||||
procedure_id: procedure_id.clone(),
|
|
||||||
data,
|
|
||||||
response_hook: response_hook.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.leaf.dispatch_call(&mut self.endpoint, incoming) {
|
|
||||||
Ok(CallReply::Reply(bytes)) => {
|
|
||||||
let frames = if let Some(hook) = response_hook {
|
|
||||||
self.send_reply_data(hook, procedure_id, bytes, true)?
|
|
||||||
} else {
|
|
||||||
RoutedRuntimeOutcome::default()
|
|
||||||
};
|
|
||||||
Ok(frames)
|
|
||||||
}
|
|
||||||
Ok(CallReply::NoReply) => Ok(RoutedRuntimeOutcome::default()),
|
|
||||||
Err(error) => {
|
|
||||||
// Dispatch failures still emit a protocol fault for the remote caller when a
|
|
||||||
// response hook exists, even though the local runtime also surfaces the error.
|
|
||||||
let _ = self.emit_internal_fault_if_possible(fault_hook)?;
|
|
||||||
Err(LeafRuntimeError::Dispatch(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_data(
|
|
||||||
&mut self,
|
|
||||||
header: PacketHeader,
|
|
||||||
message: DataMessage,
|
|
||||||
hook_key: HookKey,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let outgoing = self
|
|
||||||
.leaf
|
|
||||||
.on_data(IncomingData {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key,
|
|
||||||
})
|
|
||||||
.map_err(LeafRuntimeError::Leaf)?;
|
|
||||||
self.emit_outgoing_routed(outgoing)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_fault(
|
|
||||||
&mut self,
|
|
||||||
header: PacketHeader,
|
|
||||||
message: crate::protocol::FaultMessage,
|
|
||||||
hook_key: HookKey,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
self.leaf
|
|
||||||
.on_fault(IncomingFault {
|
|
||||||
header,
|
|
||||||
fault: message,
|
|
||||||
hook_key,
|
|
||||||
})
|
|
||||||
.map_err(LeafRuntimeError::Leaf)?;
|
|
||||||
Ok(RoutedRuntimeOutcome::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_outgoing_routed(
|
|
||||||
&mut self,
|
|
||||||
outgoing: Vec<OutgoingData>,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let mut runtime = RoutedRuntimeOutcome::default();
|
|
||||||
for packet in outgoing {
|
|
||||||
let endpoint_outcome = self.endpoint.send_data(
|
|
||||||
packet.dst_path,
|
|
||||||
packet.hook_id,
|
|
||||||
packet.procedure_id,
|
|
||||||
packet.data,
|
|
||||||
packet.end_hook,
|
|
||||||
)?;
|
|
||||||
runtime.forwarded.extend(
|
|
||||||
self.process_endpoint_outcome_routed(endpoint_outcome)?
|
|
||||||
.forwarded,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(runtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_reply_data(
|
|
||||||
&mut self,
|
|
||||||
hook: HookTarget,
|
|
||||||
procedure_id: String,
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
end_hook: bool,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let endpoint_outcome = self.endpoint.send_data(
|
|
||||||
hook.return_path,
|
|
||||||
hook.hook_id,
|
|
||||||
procedure_id,
|
|
||||||
bytes,
|
|
||||||
end_hook,
|
|
||||||
)?;
|
|
||||||
self.process_endpoint_outcome_routed(endpoint_outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_internal_fault_if_possible(
|
|
||||||
&mut self,
|
|
||||||
hook: Option<&HookTarget>,
|
|
||||||
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
|
|
||||||
let Some(hook) = hook else {
|
|
||||||
return Ok(RoutedRuntimeOutcome::default());
|
|
||||||
};
|
|
||||||
let key = HookKey::new(hook.return_path.clone(), hook.hook_id);
|
|
||||||
let outcome = self
|
|
||||||
.endpoint
|
|
||||||
.emit_fault_if_possible(Some(key), ProtocolFault::INTERNAL_ERROR)?;
|
|
||||||
self.process_endpoint_outcome_routed(outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<L> LeafRuntime<L>
|
|
||||||
where
|
|
||||||
L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error> + RouterLeaf,
|
|
||||||
{
|
|
||||||
/// Sends previously forwarded frames through the router leaf's parent/child links.
|
|
||||||
///
|
|
||||||
/// What it is: a small transport bridge from endpoint route decisions to the
|
|
||||||
/// leaf-owned connections.
|
|
||||||
///
|
|
||||||
/// Why it exists: router leaves should be able to reuse the normal protocol
|
|
||||||
/// runtime and still own the concrete forwarding mechanism.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
|
|
||||||
/// # struct ExampleLeaf;
|
|
||||||
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
|
|
||||||
/// ```
|
|
||||||
pub fn route_forwarded(
|
|
||||||
&mut self,
|
|
||||||
forwarded: Vec<ForwardedFrame>,
|
|
||||||
) -> Result<(), <L as RouterLeaf>::RouteError> {
|
|
||||||
for forwarded in forwarded {
|
|
||||||
match forwarded.route {
|
|
||||||
RouteDecision::Parent => {
|
|
||||||
self.leaf
|
|
||||||
.route_to_parent(self.endpoint.path(), forwarded.frame)?;
|
|
||||||
}
|
|
||||||
RouteDecision::Child(index) => {
|
|
||||||
let child_path = self
|
|
||||||
.endpoint
|
|
||||||
.child_routes()
|
|
||||||
.get(index)
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
self.leaf.route_to_child(&child_path, forwarded.frame)?;
|
|
||||||
}
|
|
||||||
RouteDecision::Local | RouteDecision::Drop => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes one archived call payload into a typed application request.
|
|
||||||
///
|
|
||||||
/// This exists for generated and manual leaf code that stores its own typed `rkyv` payload inside
|
|
||||||
/// protocol `CallMessage::data` bytes.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
/// use unshell::protocol::tree::{decode_call_input, encode_call_reply};
|
|
||||||
/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)]
|
|
||||||
/// struct Example { value: u32 }
|
|
||||||
/// let bytes = encode_call_reply(&Example { value: 7 })?;
|
|
||||||
/// let decoded = decode_call_input::<Example>(&bytes)?;
|
|
||||||
/// assert_eq!(decoded, Example { value: 7 });
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn decode_call_input<T>(bytes: &[u8]) -> Result<T, FrameError>
|
|
||||||
where
|
|
||||||
T: Archive,
|
|
||||||
<T as Archive>::Archived: rkyv::Portable
|
|
||||||
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>
|
|
||||||
+ rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
|
|
||||||
{
|
|
||||||
crate::protocol::deserialize_archived_bytes::<<T as Archive>::Archived, T>(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes one typed application reply into hook `Data` bytes.
|
|
||||||
///
|
|
||||||
/// This exists for generated and manual leaf code that wants to place one typed `rkyv` payload in
|
|
||||||
/// the `data` field of a returned hook packet.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use rkyv::{Archive, Deserialize, Serialize};
|
|
||||||
/// use unshell::protocol::tree::encode_call_reply;
|
|
||||||
/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)]
|
|
||||||
/// struct Example { value: u32 }
|
|
||||||
/// let bytes = encode_call_reply(&Example { value: 7 })?;
|
|
||||||
/// assert!(!bytes.is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn encode_call_reply<T>(value: &T) -> Result<Vec<u8>, FrameError>
|
|
||||||
where
|
|
||||||
T: for<'a> Serialize<
|
|
||||||
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
let bytes = to_bytes::<Error>(value).map_err(FrameError::Serialize)?;
|
|
||||||
Ok(bytes.as_slice().to_vec())
|
|
||||||
}
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
//! Packet builders and endpoint construction.
|
|
||||||
|
|
||||||
use alloc::{collections::BTreeSet, string::String, vec::Vec};
|
|
||||||
|
|
||||||
use crate::protocol::tree::{HookKey, PendingHook};
|
|
||||||
use crate::protocol::{
|
|
||||||
CallMessage, DataMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ValidationError,
|
|
||||||
encode_packet, validate_call, validate_header, validate_procedure_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::{CompiledRoutes, RouteDecision};
|
|
||||||
use super::core::{ChildRoute, EndpointError, EndpointOutcome, ProtocolEndpoint};
|
|
||||||
use crate::protocol::tree::LeafSpec;
|
|
||||||
|
|
||||||
impl ProtocolEndpoint {
|
|
||||||
fn prepare_call(
|
|
||||||
&self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
dst_leaf: Option<String>,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
response_hook_id: Option<u64>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> Result<(PacketHeader, CallMessage), EndpointError> {
|
|
||||||
let procedure_id = procedure_id.into();
|
|
||||||
validate_procedure_id(&procedure_id)?;
|
|
||||||
|
|
||||||
let response_hook = response_hook_id.map(|hook_id| HookTarget {
|
|
||||||
hook_id,
|
|
||||||
return_path: self.path.clone(),
|
|
||||||
});
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Call,
|
|
||||||
src_path: self.path.clone(),
|
|
||||||
dst_path,
|
|
||||||
dst_leaf,
|
|
||||||
hook_id: None,
|
|
||||||
};
|
|
||||||
let call = CallMessage {
|
|
||||||
procedure_id,
|
|
||||||
data,
|
|
||||||
response_hook,
|
|
||||||
};
|
|
||||||
|
|
||||||
validate_header(&header)?;
|
|
||||||
validate_call(&header, &call)?;
|
|
||||||
Ok((header, call))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_data(
|
|
||||||
&self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
hook_id: u64,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
end_hook: bool,
|
|
||||||
) -> Result<(PacketHeader, DataMessage), EndpointError> {
|
|
||||||
let procedure_id = procedure_id.into();
|
|
||||||
validate_procedure_id(&procedure_id)?;
|
|
||||||
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: self.path.clone(),
|
|
||||||
dst_path,
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(hook_id),
|
|
||||||
};
|
|
||||||
let message = DataMessage {
|
|
||||||
procedure_id,
|
|
||||||
data,
|
|
||||||
end_hook,
|
|
||||||
};
|
|
||||||
|
|
||||||
validate_header(&header)?;
|
|
||||||
Ok((header, message))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register_outbound_call_hook(
|
|
||||||
&mut self,
|
|
||||||
header: &PacketHeader,
|
|
||||||
call: &CallMessage,
|
|
||||||
) -> Result<(), EndpointError> {
|
|
||||||
// Outbound calls reserve their response hook before the frame is emitted so
|
|
||||||
// the endpoint can attribute returned Fault packets even before the callee
|
|
||||||
// accepts the call. The hook only becomes active once valid hook traffic
|
|
||||||
// comes back from the expected peer.
|
|
||||||
if let Some(hook) = &call.response_hook
|
|
||||||
&& let key = HookKey::new(hook.return_path.clone(), hook.hook_id)
|
|
||||||
&& self
|
|
||||||
.hooks
|
|
||||||
.insert_pending(
|
|
||||||
key,
|
|
||||||
PendingHook {
|
|
||||||
caller_src_path: header.dst_path.clone(),
|
|
||||||
procedure_id: call.procedure_id.clone(),
|
|
||||||
local_ended: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return Err(EndpointError::Validation(ValidationError::InvalidHookId));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Creates an endpoint with compiled routing tables for its current topology.
|
|
||||||
///
|
|
||||||
/// `parent_path` is currently used only as a presence flag. The endpoint stores its own
|
|
||||||
/// absolute `path`, and routing only needs to know whether an upward route exists.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, LeafSpec, ProtocolEndpoint};
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(
|
|
||||||
/// vec!["worker".into()],
|
|
||||||
/// Some(Vec::new()),
|
|
||||||
/// vec![ChildRoute::registered(vec!["worker".into(), "child".into()])],
|
|
||||||
/// vec![LeafSpec {
|
|
||||||
/// name: "service".into(),
|
|
||||||
/// procedures: vec!["example.service.v1.invoke".into()],
|
|
||||||
/// }],
|
|
||||||
/// );
|
|
||||||
/// let _ = endpoint;
|
|
||||||
/// ```
|
|
||||||
pub fn new(
|
|
||||||
path: Vec<String>,
|
|
||||||
parent_path: Option<Vec<String>>,
|
|
||||||
children: Vec<ChildRoute>,
|
|
||||||
leaves: Vec<LeafSpec>,
|
|
||||||
) -> Self {
|
|
||||||
let has_parent = parent_path.is_some();
|
|
||||||
let registered_child_paths = children
|
|
||||||
.iter()
|
|
||||||
.filter(|child| child.registered)
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
local_id: None,
|
|
||||||
parent_path,
|
|
||||||
routing: CompiledRoutes::new(&path, ®istered_child_paths, has_parent),
|
|
||||||
path,
|
|
||||||
children,
|
|
||||||
leaves: leaves
|
|
||||||
.into_iter()
|
|
||||||
.map(|leaf| (leaf.name.clone(), leaf))
|
|
||||||
.collect(),
|
|
||||||
endpoint_procedures: BTreeSet::new(),
|
|
||||||
hooks: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Creates a root-assumed endpoint with one local identifier and predeclared leaves.
|
|
||||||
///
|
|
||||||
/// What it is: a convenience constructor for the common bootstrap state where an endpoint has
|
|
||||||
/// one local name but has not yet been assigned a non-root path by a parent connection.
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint creation should not require every caller to manually pass an empty
|
|
||||||
/// path, no parent, and no children just to host one or more known leaves.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafSpec, ProtocolEndpoint};
|
|
||||||
/// let endpoint = ProtocolEndpoint::root(
|
|
||||||
/// "worker",
|
|
||||||
/// vec![LeafSpec {
|
|
||||||
/// name: "service".into(),
|
|
||||||
/// procedures: vec!["example.service.v1.invoke".into()],
|
|
||||||
/// }],
|
|
||||||
/// );
|
|
||||||
/// assert!(endpoint.path().is_empty());
|
|
||||||
/// assert_eq!(endpoint.local_id(), Some("worker"));
|
|
||||||
/// ```
|
|
||||||
pub fn root(local_id: impl Into<String>, leaves: Vec<LeafSpec>) -> Self {
|
|
||||||
let mut endpoint = Self::new(Vec::new(), None, Vec::new(), leaves);
|
|
||||||
endpoint.local_id = Some(local_id.into());
|
|
||||||
endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Returns the endpoint's local bootstrap identifier, if one was assigned.
|
|
||||||
///
|
|
||||||
/// What it is: a lightweight label separate from the protocol path.
|
|
||||||
///
|
|
||||||
/// Why it exists: a freshly created endpoint may know its own local identity before a parent
|
|
||||||
/// connection assigns its final tree path.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let endpoint = ProtocolEndpoint::root("worker", Vec::new());
|
|
||||||
/// assert_eq!(endpoint.local_id(), Some("worker"));
|
|
||||||
/// ```
|
|
||||||
pub fn local_id(&self) -> Option<&str> {
|
|
||||||
self.local_id.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the absolute path of this endpoint's direct parent, if one exists.
|
|
||||||
///
|
|
||||||
/// What it is: the currently configured one-hop parent boundary for this
|
|
||||||
/// endpoint.
|
|
||||||
///
|
|
||||||
/// Why it exists: router-style leaves need to expose and inspect the tree edge
|
|
||||||
/// they use for upstream traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
|
|
||||||
/// assert_eq!(endpoint.parent_path(), Some([].as_slice()));
|
|
||||||
/// ```
|
|
||||||
pub fn parent_path(&self) -> Option<&[String]> {
|
|
||||||
self.parent_path.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the direct child routes currently known to this endpoint.
|
|
||||||
///
|
|
||||||
/// What it is: the local routing-table inputs for direct descendants.
|
|
||||||
///
|
|
||||||
/// Why it exists: management leaves often need to inspect or mirror the child
|
|
||||||
/// topology they are controlling.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(
|
|
||||||
/// vec!["root".into()],
|
|
||||||
/// None,
|
|
||||||
/// vec![ChildRoute::registered(vec!["root".into(), "child".into()])],
|
|
||||||
/// Vec::new(),
|
|
||||||
/// );
|
|
||||||
/// assert_eq!(endpoint.child_routes().len(), 1);
|
|
||||||
/// ```
|
|
||||||
pub fn child_routes(&self) -> &[ChildRoute] {
|
|
||||||
&self.children
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces the configured direct parent path and recompiles local routing.
|
|
||||||
///
|
|
||||||
/// What it is: the supported way to attach or detach this endpoint from its
|
|
||||||
/// upstream boundary.
|
|
||||||
///
|
|
||||||
/// Why it exists: a router leaf should be able to promote or remove its parent
|
|
||||||
/// connection without rebuilding the entire endpoint.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(vec!["root".into(), "worker".into()], Some(vec!["root".into()]), Vec::new(), Vec::new());
|
|
||||||
/// endpoint.set_parent_path(None)?;
|
|
||||||
/// assert!(endpoint.parent_path().is_none());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn set_parent_path(
|
|
||||||
&mut self,
|
|
||||||
parent_path: Option<Vec<String>>,
|
|
||||||
) -> Result<(), EndpointError> {
|
|
||||||
if let Some(path) = parent_path.as_deref() {
|
|
||||||
self.validate_direct_parent_path(path)?;
|
|
||||||
}
|
|
||||||
self.parent_path = parent_path;
|
|
||||||
self.rebuild_routing();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts or updates one direct child route and recompiles local routing.
|
|
||||||
///
|
|
||||||
/// What it is: the supported mutation API for the endpoint's child list.
|
|
||||||
///
|
|
||||||
/// Why it exists: router-management leaves need one invariant-preserving way to
|
|
||||||
/// reflect child connection changes into path routing.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(vec!["root".into()], None, Vec::new(), Vec::new());
|
|
||||||
/// endpoint.upsert_child_route(ChildRoute::registered(vec!["root".into(), "child".into()]))?;
|
|
||||||
/// assert_eq!(endpoint.child_routes().len(), 1);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn upsert_child_route(&mut self, route: ChildRoute) -> Result<(), EndpointError> {
|
|
||||||
self.validate_direct_child_path(&route.path)?;
|
|
||||||
if let Some(existing) = self
|
|
||||||
.children
|
|
||||||
.iter_mut()
|
|
||||||
.find(|child| child.path == route.path)
|
|
||||||
{
|
|
||||||
*existing = route;
|
|
||||||
} else {
|
|
||||||
self.children.push(route);
|
|
||||||
}
|
|
||||||
self.rebuild_routing();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes one direct child route by absolute path and recompiles local routing.
|
|
||||||
///
|
|
||||||
/// What it is: the supported mutation API for pruning a direct descendant.
|
|
||||||
///
|
|
||||||
/// Why it exists: connection-management leaves need to tear down routes without
|
|
||||||
/// mutating the endpoint internals directly.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
/// vec!["root".into()],
|
|
||||||
/// None,
|
|
||||||
/// vec![ChildRoute::registered(vec!["root".into(), "child".into()])],
|
|
||||||
/// Vec::new(),
|
|
||||||
/// );
|
|
||||||
/// assert!(endpoint.remove_child_route(&[String::from("root"), String::from("child")]));
|
|
||||||
/// assert!(endpoint.child_routes().is_empty());
|
|
||||||
/// ```
|
|
||||||
pub fn remove_child_route(&mut self, path: &[String]) -> bool {
|
|
||||||
let original_len = self.children.len();
|
|
||||||
self.children.retain(|child| child.path != path);
|
|
||||||
let removed = self.children.len() != original_len;
|
|
||||||
if removed {
|
|
||||||
self.rebuild_routing();
|
|
||||||
}
|
|
||||||
removed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a procedure that is handled directly by the endpoint.
|
|
||||||
///
|
|
||||||
/// Endpoint-level procedures exist for protocol services that are not attached to one leaf,
|
|
||||||
/// such as built-in runtime behavior.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
|
||||||
/// endpoint.add_endpoint_procedure("example.endpoint.v1.health")?;
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn add_endpoint_procedure(
|
|
||||||
&mut self,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
) -> Result<(), EndpointError> {
|
|
||||||
let procedure_id = procedure_id.into();
|
|
||||||
validate_procedure_id(&procedure_id)?;
|
|
||||||
self.endpoint_procedures.insert(procedure_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
/// Allocates a hook id scoped to this endpoint path.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
|
||||||
/// let hook_id = endpoint.allocate_hook_id();
|
|
||||||
/// assert_ne!(hook_id, 0);
|
|
||||||
/// ```
|
|
||||||
pub fn allocate_hook_id(&mut self) -> u64 {
|
|
||||||
self.hooks.allocate_hook_id(&self.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_routing(&mut self) {
|
|
||||||
let registered_child_paths = self
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.filter(|child| child.registered)
|
|
||||||
.map(|child| child.path.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
self.routing = CompiledRoutes::new(
|
|
||||||
&self.path,
|
|
||||||
®istered_child_paths,
|
|
||||||
self.parent_path.is_some(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_direct_parent_path(&self, parent_path: &[String]) -> Result<(), EndpointError> {
|
|
||||||
let Some((_, expected_parent)) = self.path.split_last() else {
|
|
||||||
return Err(EndpointError::Validation(
|
|
||||||
ValidationError::TopologyInvariant("root endpoints cannot declare a parent path"),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if parent_path != expected_parent {
|
|
||||||
return Err(EndpointError::Validation(
|
|
||||||
ValidationError::TopologyInvariant(
|
|
||||||
"parent path must equal the direct path prefix of this endpoint",
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_direct_child_path(&self, child_path: &[String]) -> Result<(), EndpointError> {
|
|
||||||
if child_path.len() != self.path.len() + 1 || !child_path.starts_with(&self.path) {
|
|
||||||
return Err(EndpointError::Validation(
|
|
||||||
ValidationError::TopologyInvariant(
|
|
||||||
"child path must be one direct descendant of this endpoint",
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes a call frame without routing it through the local endpoint.
|
|
||||||
///
|
|
||||||
/// This exists for callers that want a fully encoded outbound frame while handling transport
|
|
||||||
/// themselves.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
|
||||||
/// let frame = endpoint.make_call(
|
|
||||||
/// vec!["worker".into()],
|
|
||||||
/// Some("service".into()),
|
|
||||||
/// "example.service.v1.invoke",
|
|
||||||
/// None,
|
|
||||||
/// vec![1, 2, 3],
|
|
||||||
/// )?;
|
|
||||||
/// assert!(!frame.is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn make_call(
|
|
||||||
&mut self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
dst_leaf: Option<String>,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
response_hook_id: Option<u64>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> Result<FrameBytes, EndpointError> {
|
|
||||||
let (header, call) =
|
|
||||||
self.prepare_call(dst_path, dst_leaf, procedure_id, response_hook_id, data)?;
|
|
||||||
self.register_outbound_call_hook(&header, &call)?;
|
|
||||||
Ok(encode_packet(&header, &call)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds and immediately routes a call, producing either a forward or a local event.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, EndpointOutcome, ProtocolEndpoint};
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(
|
|
||||||
/// Vec::new(),
|
|
||||||
/// None,
|
|
||||||
/// vec![ChildRoute::registered(vec!["worker".into()])],
|
|
||||||
/// Vec::new(),
|
|
||||||
/// );
|
|
||||||
/// let outcome = endpoint.send_call(
|
|
||||||
/// vec!["worker".into()],
|
|
||||||
/// Some("service".into()),
|
|
||||||
/// "example.service.v1.invoke",
|
|
||||||
/// None,
|
|
||||||
/// vec![],
|
|
||||||
/// )?;
|
|
||||||
/// assert!(matches!(outcome, EndpointOutcome::Forward { .. } | EndpointOutcome::Dropped | EndpointOutcome::Local(_)));
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn send_call(
|
|
||||||
&mut self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
dst_leaf: Option<String>,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
response_hook_id: Option<u64>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let (header, call) =
|
|
||||||
self.prepare_call(dst_path, dst_leaf, procedure_id, response_hook_id, data)?;
|
|
||||||
self.register_outbound_call_hook(&header, &call)?;
|
|
||||||
|
|
||||||
match self.decide_route(&header.dst_path) {
|
|
||||||
RouteDecision::Local => self.handle_local_call(header, call),
|
|
||||||
RouteDecision::Drop => {
|
|
||||||
self.rollback_pending_call_hook(&call);
|
|
||||||
Ok(EndpointOutcome::Dropped)
|
|
||||||
}
|
|
||||||
route => Ok(EndpointOutcome::Forward {
|
|
||||||
route,
|
|
||||||
frame: encode_packet(&header, &call)?,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes a data frame without routing it through the local endpoint.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
|
||||||
/// let frame = endpoint.make_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![1], false)?;
|
|
||||||
/// assert!(!frame.is_empty());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn make_data(
|
|
||||||
&self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
hook_id: u64,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
end_hook: bool,
|
|
||||||
) -> Result<FrameBytes, EndpointError> {
|
|
||||||
let (header, message) =
|
|
||||||
self.prepare_data(dst_path, hook_id, procedure_id, data, end_hook)?;
|
|
||||||
Ok(encode_packet(&header, &message)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds and immediately routes a data packet, updating local hook state for end-of-stream.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
|
||||||
/// let _ = endpoint.send_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![], false);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
|
|
||||||
/// ```
|
|
||||||
pub fn send_data(
|
|
||||||
&mut self,
|
|
||||||
dst_path: Vec<String>,
|
|
||||||
hook_id: u64,
|
|
||||||
procedure_id: impl Into<String>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
end_hook: bool,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
if let Some(active_key) = self
|
|
||||||
.hooks
|
|
||||||
.resolve_active_key(&dst_path, hook_id, &self.path)
|
|
||||||
&& self
|
|
||||||
.hooks
|
|
||||||
.active(&active_key)
|
|
||||||
.is_some_and(|active| active.local_ended)
|
|
||||||
{
|
|
||||||
return Err(EndpointError::Validation(ValidationError::HookInvariant(
|
|
||||||
"local side already closed this hook",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let local_end_dst_path = dst_path.clone();
|
|
||||||
let host_key = HookKey::new(self.path.clone(), hook_id);
|
|
||||||
let (header, message) =
|
|
||||||
self.prepare_data(dst_path, hook_id, procedure_id, data, end_hook)?;
|
|
||||||
|
|
||||||
if end_hook {
|
|
||||||
self.mark_local_stream_end(&local_end_dst_path, hook_id, &host_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.decide_route(&header.dst_path) {
|
|
||||||
RouteDecision::Local => self.handle_local_data(header, message),
|
|
||||||
RouteDecision::Drop => Ok(EndpointOutcome::Dropped),
|
|
||||||
route => Ok(EndpointOutcome::Forward {
|
|
||||||
route,
|
|
||||||
frame: encode_packet(&header, &message)?,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_pending_call_hook(&mut self, call: &CallMessage) {
|
|
||||||
if let Some(hook) = &call.response_hook {
|
|
||||||
self.hooks
|
|
||||||
.remove_pending(&HookKey::new(hook.return_path.clone(), hook.hook_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_local_stream_end(&mut self, dst_path: &[String], hook_id: u64, host_key: &HookKey) {
|
|
||||||
// Locally-originated streams may not have been resolved against a peer yet, so fall
|
|
||||||
// back to the endpoint's own hook key shape when closing them.
|
|
||||||
let local_hook_key = self
|
|
||||||
.hooks
|
|
||||||
.resolve_active_key(dst_path, hook_id, &self.path)
|
|
||||||
.unwrap_or_else(|| host_key.clone());
|
|
||||||
if self.hooks.pending(host_key).is_some() {
|
|
||||||
self.hooks.mark_pending_local_end(host_key);
|
|
||||||
} else if self.hooks.mark_local_end(&local_hook_key) {
|
|
||||||
self.hooks.remove_active(&local_hook_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
//! Core endpoint state and externally visible types.
|
|
||||||
|
|
||||||
use alloc::{
|
|
||||||
collections::{BTreeMap, BTreeSet},
|
|
||||||
string::String,
|
|
||||||
vec::Vec,
|
|
||||||
};
|
|
||||||
use core::fmt;
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
CallMessage, DataMessage, FaultMessage, FrameBytes, FrameError, PacketHeader, ValidationError,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::{CompiledRoutes, HookKey, HookTable, RouteDecision};
|
|
||||||
|
|
||||||
/// Routing metadata for one direct child endpoint.
|
|
||||||
///
|
|
||||||
/// This exists so one endpoint can distinguish topology from registration state. A child path may
|
|
||||||
/// be known structurally while still being excluded from route decisions.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ChildRoute;
|
|
||||||
/// let route = ChildRoute::registered(vec!["root".into(), "worker".into()]);
|
|
||||||
/// assert!(route.registered);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ChildRoute {
|
|
||||||
/// Absolute path for the child endpoint inside the protocol tree.
|
|
||||||
pub path: Vec<String>,
|
|
||||||
/// Whether this child currently participates in routing decisions.
|
|
||||||
pub registered: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChildRoute {
|
|
||||||
#[must_use]
|
|
||||||
/// Builds one child route that is immediately eligible for routing decisions.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ChildRoute;
|
|
||||||
/// let route = ChildRoute::registered(vec!["worker".into()]);
|
|
||||||
/// assert!(route.registered);
|
|
||||||
/// ```
|
|
||||||
pub fn registered(path: Vec<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
registered: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Procedures exposed by a named leaf attached to this endpoint.
|
|
||||||
///
|
|
||||||
/// This exists so endpoint construction can advertise one leaf's callable procedure ids up front,
|
|
||||||
/// before any runtime packets arrive.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::LeafSpec;
|
|
||||||
/// let leaf = LeafSpec {
|
|
||||||
/// name: "service".into(),
|
|
||||||
/// procedures: vec!["example.service.v1.invoke".into()],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(leaf.procedures.len(), 1);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LeafSpec {
|
|
||||||
/// Leaf identifier used in packet headers.
|
|
||||||
pub name: String,
|
|
||||||
/// Procedures this leaf accepts.
|
|
||||||
pub procedures: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where an inbound frame entered this endpoint.
|
|
||||||
///
|
|
||||||
/// This exists because protocol validation depends on whether a packet arrived from the parent,
|
|
||||||
/// one child subtree, or the endpoint itself.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::Ingress;
|
|
||||||
/// let ingress = Ingress::Child(vec!["root".into(), "worker".into()]);
|
|
||||||
/// assert!(matches!(ingress, Ingress::Child(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Ingress {
|
|
||||||
/// The frame arrived from the parent side of the tree.
|
|
||||||
Parent,
|
|
||||||
/// The frame arrived from one direct child, identified by that child's absolute path.
|
|
||||||
Child(Vec<String>),
|
|
||||||
/// The frame originated locally at this endpoint.
|
|
||||||
Local,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event produced when the endpoint handles a packet locally.
|
|
||||||
///
|
|
||||||
/// This is the validated handoff boundary between transport/routing code and application-facing
|
|
||||||
/// runtimes layered on top of `ProtocolEndpoint`.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType};
|
|
||||||
/// use unshell::protocol::tree::LocalEvent;
|
|
||||||
/// let event = LocalEvent::Call {
|
|
||||||
/// header: PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: vec!["root".into()],
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// },
|
|
||||||
/// message: CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// },
|
|
||||||
/// };
|
|
||||||
/// assert!(matches!(event, LocalEvent::Call { .. }));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum LocalEvent {
|
|
||||||
/// One opening `Call` packet validated and delivered to local code.
|
|
||||||
Call {
|
|
||||||
/// Validated protocol header for the packet.
|
|
||||||
header: PacketHeader,
|
|
||||||
/// Deserialized call payload.
|
|
||||||
message: CallMessage,
|
|
||||||
},
|
|
||||||
/// One hook-associated `Data` packet validated and delivered locally.
|
|
||||||
Data {
|
|
||||||
/// Validated protocol header for the packet.
|
|
||||||
header: PacketHeader,
|
|
||||||
/// Deserialized data payload.
|
|
||||||
message: DataMessage,
|
|
||||||
/// Canonical host-scoped hook key resolved for this hook stream.
|
|
||||||
hook_key: HookKey,
|
|
||||||
},
|
|
||||||
/// One hook-associated `Fault` packet validated and delivered locally.
|
|
||||||
Fault {
|
|
||||||
/// Validated protocol header for the packet.
|
|
||||||
header: PacketHeader,
|
|
||||||
/// Deserialized fault payload.
|
|
||||||
message: FaultMessage,
|
|
||||||
/// Canonical host-scoped hook key resolved for this hook stream.
|
|
||||||
hook_key: HookKey,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of processing a frame or building a locally-sent packet.
|
|
||||||
///
|
|
||||||
/// This exists so callers can distinguish forwarding, local delivery, and intentional drops
|
|
||||||
/// without treating normal protocol routing outcomes as errors.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::FrameBytes;
|
|
||||||
/// use unshell::protocol::tree::{EndpointOutcome, RouteDecision};
|
|
||||||
/// let outcome = EndpointOutcome::Forward {
|
|
||||||
/// route: RouteDecision::Parent,
|
|
||||||
/// frame: FrameBytes::new(),
|
|
||||||
/// };
|
|
||||||
/// assert!(matches!(outcome, EndpointOutcome::Forward { .. }));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum EndpointOutcome {
|
|
||||||
/// Frame to forward, together with the next routing decision.
|
|
||||||
Forward {
|
|
||||||
/// The next routing decision chosen for the forwarded frame.
|
|
||||||
route: RouteDecision,
|
|
||||||
/// The encoded frame bytes to send along that route.
|
|
||||||
frame: FrameBytes,
|
|
||||||
},
|
|
||||||
/// Locally-delivered protocol event.
|
|
||||||
Local(LocalEvent),
|
|
||||||
/// Packet intentionally discarded.
|
|
||||||
Dropped,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One framed packet together with the next hop selected by endpoint routing.
|
|
||||||
///
|
|
||||||
/// What it is: a transport-ready frame paired with the resolved direction the
|
|
||||||
/// endpoint chose for it.
|
|
||||||
///
|
|
||||||
/// Why it exists: high-level runtimes often flatten forwarded traffic down to raw
|
|
||||||
/// bytes, but router-host leaves need the route decision so they can choose the
|
|
||||||
/// correct parent or child connection.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::FrameBytes;
|
|
||||||
/// use unshell::protocol::tree::{ForwardedFrame, RouteDecision};
|
|
||||||
/// let forwarded = ForwardedFrame {
|
|
||||||
/// route: RouteDecision::Parent,
|
|
||||||
/// frame: FrameBytes::new(),
|
|
||||||
/// };
|
|
||||||
/// assert!(matches!(forwarded.route, RouteDecision::Parent));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ForwardedFrame {
|
|
||||||
/// The next hop selected by the endpoint runtime.
|
|
||||||
pub route: RouteDecision,
|
|
||||||
/// The encoded protocol frame to send over that hop.
|
|
||||||
pub frame: FrameBytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error surfaced while validating or encoding protocol frames.
|
|
||||||
///
|
|
||||||
/// This exists so endpoint callers can preserve the distinction between malformed wire/archive
|
|
||||||
/// data and semantic protocol invariant failures.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{FrameError, ValidationError};
|
|
||||||
/// use unshell::protocol::tree::EndpointError;
|
|
||||||
/// let error = EndpointError::Frame(FrameError::Truncated);
|
|
||||||
/// assert!(matches!(error, EndpointError::Frame(_)));
|
|
||||||
/// let validation = EndpointError::Validation(ValidationError::InvalidHookId);
|
|
||||||
/// assert!(matches!(validation, EndpointError::Validation(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum EndpointError {
|
|
||||||
/// Framing, archive decode, or archive encode failed.
|
|
||||||
Frame(FrameError),
|
|
||||||
/// One protocol invariant failed validation.
|
|
||||||
Validation(ValidationError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for EndpointError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Frame(error) => write!(f, "{error}"),
|
|
||||||
Self::Validation(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::error::Error for EndpointError {}
|
|
||||||
|
|
||||||
impl From<FrameError> for EndpointError {
|
|
||||||
fn from(value: FrameError) -> Self {
|
|
||||||
Self::Frame(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ValidationError> for EndpointError {
|
|
||||||
fn from(value: ValidationError) -> Self {
|
|
||||||
Self::Validation(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal interface implemented by protocol-tree endpoints.
|
|
||||||
///
|
|
||||||
/// This exists so higher-level runtimes can depend on one small receive/path surface instead of a
|
|
||||||
/// concrete endpoint implementation.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, Endpoint, Ingress, ProtocolEndpoint};
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, vec![ChildRoute::registered(vec!["worker".into()])], Vec::new());
|
|
||||||
/// assert_eq!(endpoint.path(), &Vec::<String>::new());
|
|
||||||
/// let _ = Ingress::Local;
|
|
||||||
/// ```
|
|
||||||
pub trait Endpoint {
|
|
||||||
/// Returns this endpoint's absolute path.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, Endpoint, ProtocolEndpoint};
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, vec![ChildRoute::registered(vec!["worker".into()])], Vec::new());
|
|
||||||
/// assert!(endpoint.path().is_empty());
|
|
||||||
/// ```
|
|
||||||
fn path(&self) -> &[String];
|
|
||||||
|
|
||||||
/// Processes one inbound frame from the given ingress.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet};
|
|
||||||
/// use unshell::protocol::tree::{Endpoint, Ingress, ProtocolEndpoint};
|
|
||||||
/// let mut endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
|
|
||||||
/// let frame = encode_packet(&PacketHeader {
|
|
||||||
/// packet_type: PacketType::Call,
|
|
||||||
/// src_path: Vec::new(),
|
|
||||||
/// dst_path: vec!["worker".into()],
|
|
||||||
/// dst_leaf: None,
|
|
||||||
/// hook_id: None,
|
|
||||||
/// }, &CallMessage {
|
|
||||||
/// procedure_id: "example.invoke".into(),
|
|
||||||
/// data: vec![],
|
|
||||||
/// response_hook: None,
|
|
||||||
/// })?;
|
|
||||||
/// let _outcome = endpoint.receive(&Ingress::Parent, frame);
|
|
||||||
/// # Ok::<(), unshell::protocol::FrameError>(())
|
|
||||||
/// ```
|
|
||||||
fn receive(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime state for one endpoint in the protocol tree.
|
|
||||||
///
|
|
||||||
/// This exists as the central protocol node that owns route tables, local leaf metadata, and hook
|
|
||||||
/// lifecycle state for one endpoint path.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolEndpoint;
|
|
||||||
/// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
|
|
||||||
/// let _ = endpoint;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct ProtocolEndpoint {
|
|
||||||
pub(crate) local_id: Option<String>,
|
|
||||||
pub(crate) path: Vec<String>,
|
|
||||||
pub(crate) parent_path: Option<Vec<String>>,
|
|
||||||
pub(crate) children: Vec<ChildRoute>,
|
|
||||||
pub(crate) routing: CompiledRoutes,
|
|
||||||
pub(crate) leaves: BTreeMap<String, LeafSpec>,
|
|
||||||
pub(crate) endpoint_procedures: BTreeSet<String>,
|
|
||||||
pub(crate) hooks: HookTable,
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
//! Hook-state transitions and route helpers.
|
|
||||||
|
|
||||||
use alloc::string::String;
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
DataMessage, FaultMessage, PacketHeader, PacketType, ProtocolFault, encode_packet,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::{HookKey, RouteDecision};
|
|
||||||
use super::core::{EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint};
|
|
||||||
|
|
||||||
impl ProtocolEndpoint {
|
|
||||||
pub(crate) fn emit_fault_if_possible(
|
|
||||||
&mut self,
|
|
||||||
key: Option<HookKey>,
|
|
||||||
fault: ProtocolFault,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let Some(key) = key else {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
};
|
|
||||||
|
|
||||||
self.hooks.remove_pending(&key);
|
|
||||||
self.hooks.remove_active(&key);
|
|
||||||
|
|
||||||
let header = PacketHeader {
|
|
||||||
packet_type: PacketType::Fault,
|
|
||||||
src_path: self.path.clone(),
|
|
||||||
dst_path: key.return_path.clone(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(key.hook_id),
|
|
||||||
};
|
|
||||||
let message = FaultMessage { fault };
|
|
||||||
|
|
||||||
match self.decide_route(&key.return_path) {
|
|
||||||
RouteDecision::Local => Ok(EndpointOutcome::Local(LocalEvent::Fault {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key: key,
|
|
||||||
})),
|
|
||||||
route => Ok(EndpointOutcome::Forward {
|
|
||||||
route,
|
|
||||||
frame: encode_packet(&header, &message)?,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn handle_local_data(
|
|
||||||
&mut self,
|
|
||||||
header: PacketHeader,
|
|
||||||
message: DataMessage,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let hook_id = header.hook_id.expect("validated");
|
|
||||||
let key = if let Some(key) =
|
|
||||||
self.hooks
|
|
||||||
.resolve_active_key(&self.path, hook_id, &header.src_path)
|
|
||||||
{
|
|
||||||
key
|
|
||||||
} else {
|
|
||||||
let pending_key = HookKey::new(self.path.clone(), hook_id);
|
|
||||||
if self.hooks.pending(&pending_key).is_some_and(|pending| {
|
|
||||||
pending.caller_src_path == header.src_path
|
|
||||||
&& pending.procedure_id == message.procedure_id
|
|
||||||
}) {
|
|
||||||
self.hooks.activate_pending(&pending_key);
|
|
||||||
pending_key
|
|
||||||
} else {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(active) = self.hooks.active(&key) else {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
};
|
|
||||||
|
|
||||||
if active.peer_path != header.src_path {
|
|
||||||
// A reused hook id from the wrong peer is treated as terminal for this hook,
|
|
||||||
// because the endpoint can no longer trust future traffic on it.
|
|
||||||
self.hooks.remove_active(&key);
|
|
||||||
return self.emit_fault_if_possible(Some(key), ProtocolFault::INVALID_HOOK_PEER);
|
|
||||||
}
|
|
||||||
|
|
||||||
if active.procedure_id != message.procedure_id {
|
|
||||||
// Data frames stay bound to the procedure chosen by the original call.
|
|
||||||
// A procedure mismatch is dropped rather than faulted because the wrong peer may be
|
|
||||||
// replaying stale traffic, and converting that into a terminal hook fault would let a
|
|
||||||
// stray packet tear down an otherwise valid stream.
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.end_hook && self.hooks.mark_peer_end(&key) {
|
|
||||||
self.hooks.remove_active(&key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(EndpointOutcome::Local(LocalEvent::Data {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key: key,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn handle_local_fault(
|
|
||||||
&mut self,
|
|
||||||
header: PacketHeader,
|
|
||||||
message: FaultMessage,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let hook_id = header.hook_id.expect("validated");
|
|
||||||
if let Some(key) = self
|
|
||||||
.hooks
|
|
||||||
.resolve_active_key(&self.path, hook_id, &header.src_path)
|
|
||||||
{
|
|
||||||
self.hooks.remove_active(&key);
|
|
||||||
return Ok(EndpointOutcome::Local(LocalEvent::Fault {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key: key,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pending_key = HookKey::new(self.path.clone(), hook_id);
|
|
||||||
if self
|
|
||||||
.hooks
|
|
||||||
.pending(&pending_key)
|
|
||||||
.is_some_and(|pending| pending.caller_src_path == header.src_path)
|
|
||||||
{
|
|
||||||
self.hooks.remove_pending(&pending_key);
|
|
||||||
return Ok(EndpointOutcome::Local(LocalEvent::Fault {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key: pending_key,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(EndpointOutcome::Dropped)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decide_route(&self, dst_path: &[String]) -> RouteDecision {
|
|
||||||
self.routing.route(dst_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether one `src_path` is topologically valid for the ingress side that delivered
|
|
||||||
/// the frame.
|
|
||||||
///
|
|
||||||
/// Parent ingress may carry packets from ancestors, siblings, or the endpoint itself, but not
|
|
||||||
/// from descendants pretending to be upstream. Child ingress may only carry packets from that
|
|
||||||
/// child subtree, and local ingress must exactly match the endpoint path.
|
|
||||||
pub(crate) fn valid_source_for_ingress(&self, ingress: &Ingress, src_path: &[String]) -> bool {
|
|
||||||
match ingress {
|
|
||||||
Ingress::Parent => {
|
|
||||||
// Parent ingress may carry packets from ancestors, siblings, or the endpoint
|
|
||||||
// itself, but not from descendants pretending to be upstream.
|
|
||||||
if src_path.len() < self.path.len() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if src_path.len() == self.path.len() {
|
|
||||||
return src_path == self.path;
|
|
||||||
}
|
|
||||||
!src_path.starts_with(&self.path)
|
|
||||||
}
|
|
||||||
Ingress::Child(child_path) => src_path.starts_with(child_path),
|
|
||||||
Ingress::Local => src_path == self.path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//! Introspection response generation.
|
|
||||||
|
|
||||||
use alloc::{string::String, vec::Vec};
|
|
||||||
use rkyv::{rancor::Error as RkyvError, to_bytes};
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
DataMessage, EndpointIntrospection, FrameError, LeafIntrospection, LeafIntrospectionSummary,
|
|
||||||
PacketHeader, PacketType, ProtocolFault, encode_packet,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::HookKey;
|
|
||||||
use super::core::{EndpointError, EndpointOutcome, ProtocolEndpoint};
|
|
||||||
|
|
||||||
impl ProtocolEndpoint {
|
|
||||||
pub(crate) fn handle_introspection(
|
|
||||||
&mut self,
|
|
||||||
header: &PacketHeader,
|
|
||||||
key: Option<HookKey>,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let Some(key) = key else {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
};
|
|
||||||
|
|
||||||
let response_payload = if let Some(leaf_name) = &header.dst_leaf {
|
|
||||||
let Some(leaf) = self.leaves.get(leaf_name) else {
|
|
||||||
return self.emit_fault_if_possible(Some(key), ProtocolFault::UNKNOWN_LEAF);
|
|
||||||
};
|
|
||||||
self.serialize_introspection(&LeafIntrospection {
|
|
||||||
leaf_name: leaf_name.clone(),
|
|
||||||
procedures: leaf.procedures.clone(),
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
self.serialize_introspection(&EndpointIntrospection {
|
|
||||||
sub_endpoints: self.direct_registered_child_names(),
|
|
||||||
leaves: self
|
|
||||||
.leaves
|
|
||||||
.values()
|
|
||||||
.map(|leaf| LeafIntrospectionSummary {
|
|
||||||
leaf_name: leaf.name.clone(),
|
|
||||||
procedures: leaf.procedures.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
let response_header = PacketHeader {
|
|
||||||
packet_type: PacketType::Data,
|
|
||||||
src_path: self.path.clone(),
|
|
||||||
dst_path: key.return_path.clone(),
|
|
||||||
dst_leaf: None,
|
|
||||||
hook_id: Some(key.hook_id),
|
|
||||||
};
|
|
||||||
let response = DataMessage {
|
|
||||||
procedure_id: String::new(),
|
|
||||||
data: response_payload,
|
|
||||||
end_hook: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Introspection always completes in a single response frame.
|
|
||||||
if self.hooks.mark_local_end(&key) {
|
|
||||||
self.hooks.remove_active(&key);
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.decide_route(&key.return_path) {
|
|
||||||
super::super::RouteDecision::Local => {
|
|
||||||
Ok(EndpointOutcome::Local(super::core::LocalEvent::Data {
|
|
||||||
header: response_header,
|
|
||||||
message: response,
|
|
||||||
hook_key: key,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
route => Ok(EndpointOutcome::Forward {
|
|
||||||
route,
|
|
||||||
frame: encode_packet(&response_header, &response)?,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn direct_registered_child_names(&self) -> Vec<String> {
|
|
||||||
self.children
|
|
||||||
.iter()
|
|
||||||
.filter(|child| child.registered)
|
|
||||||
// Child routes store absolute endpoint paths. Index the first segment below the
|
|
||||||
// current endpoint so discovery only reports direct descendants.
|
|
||||||
.filter_map(|child| child.path.get(self.path.len()).cloned())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_introspection<T>(&self, value: &T) -> Result<Vec<u8>, EndpointError>
|
|
||||||
where
|
|
||||||
T: for<'a> rkyv::Serialize<
|
|
||||||
rkyv::api::high::HighSerializer<
|
|
||||||
rkyv::util::AlignedVec,
|
|
||||||
rkyv::ser::allocator::ArenaHandle<'a>,
|
|
||||||
RkyvError,
|
|
||||||
>,
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
to_bytes::<RkyvError>(value)
|
|
||||||
.map_err(|error| EndpointError::Frame(FrameError::Serialize(error)))
|
|
||||||
.map(|bytes| bytes.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! Protocol-tree endpoint runtime.
|
|
||||||
//!
|
|
||||||
//! This module holds the state machine that validates ingress, decides whether a
|
|
||||||
//! packet should be handled locally or forwarded, and manages hook lifetimes for
|
|
||||||
//! call/data/fault exchanges.
|
|
||||||
|
|
||||||
mod builders;
|
|
||||||
mod core;
|
|
||||||
mod hooks;
|
|
||||||
mod introspection;
|
|
||||||
mod receive;
|
|
||||||
|
|
||||||
pub use core::{
|
|
||||||
ChildRoute, Endpoint, EndpointError, EndpointOutcome, ForwardedFrame, Ingress, LeafSpec,
|
|
||||||
LocalEvent, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
//! Packet ingress and local call dispatch.
|
|
||||||
|
|
||||||
use crate::protocol::types::{ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage};
|
|
||||||
use crate::protocol::{
|
|
||||||
CallMessage, ProtocolFault, decode_frame, deserialize_archived_bytes,
|
|
||||||
introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::super::{ActiveHook, HookKey, RouteDecision};
|
|
||||||
use super::core::{
|
|
||||||
Endpoint, EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl ProtocolEndpoint {
|
|
||||||
fn local_procedure_fault(
|
|
||||||
&self,
|
|
||||||
dst_leaf: Option<&str>,
|
|
||||||
procedure_id: &str,
|
|
||||||
) -> Option<ProtocolFault> {
|
|
||||||
match dst_leaf {
|
|
||||||
Some(leaf_name) => match self.leaves.get(leaf_name) {
|
|
||||||
Some(leaf) => (!leaf
|
|
||||||
.procedures
|
|
||||||
.iter()
|
|
||||||
.any(|procedure| procedure == procedure_id))
|
|
||||||
.then_some(ProtocolFault::UNKNOWN_PROCEDURE),
|
|
||||||
None => Some(ProtocolFault::UNKNOWN_LEAF),
|
|
||||||
},
|
|
||||||
None => (!self.endpoint_procedures.contains(procedure_id))
|
|
||||||
.then_some(ProtocolFault::UNKNOWN_PROCEDURE),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn handle_local_call(
|
|
||||||
&mut self,
|
|
||||||
header: crate::protocol::PacketHeader,
|
|
||||||
message: CallMessage,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let key = message
|
|
||||||
.response_hook
|
|
||||||
.as_ref()
|
|
||||||
.map(|hook| HookKey::new(hook.return_path.clone(), hook.hook_id));
|
|
||||||
|
|
||||||
if message.procedure_id == INTROSPECTION_PROCEDURE_ID {
|
|
||||||
return self.handle_introspection(&header, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(fault) =
|
|
||||||
self.local_procedure_fault(header.dst_leaf.as_deref(), &message.procedure_id)
|
|
||||||
{
|
|
||||||
return self.emit_fault_if_possible(key, fault);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(hook) = &message.response_hook
|
|
||||||
&& hook.return_path != self.path
|
|
||||||
{
|
|
||||||
// Calls targeting this endpoint may still ask another endpoint to host the response
|
|
||||||
// hook. Only register a local active hook when the response path escapes this node.
|
|
||||||
let Some(key) = key.clone() else {
|
|
||||||
unreachable!("response_hook checked above");
|
|
||||||
};
|
|
||||||
if self
|
|
||||||
.hooks
|
|
||||||
.insert_active(
|
|
||||||
key.clone(),
|
|
||||||
ActiveHook {
|
|
||||||
peer_path: header.src_path.clone(),
|
|
||||||
procedure_id: message.procedure_id.clone(),
|
|
||||||
local_ended: false,
|
|
||||||
peer_ended: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return self.emit_fault_if_possible(Some(key), ProtocolFault::INTERNAL_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(EndpointOutcome::Local(LocalEvent::Call { header, message }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receive_call(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
parsed: crate::protocol::ParsedFrame<'_>,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
// Calls only enter from the parent side of the tree or from the endpoint itself.
|
|
||||||
// Children can return data/faults, but they do not initiate new calls through this node.
|
|
||||||
if !matches!(ingress, Ingress::Parent | Ingress::Local) {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (header, payload) = parsed.into_parts();
|
|
||||||
let message = deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(payload)?;
|
|
||||||
validate_call(&header, &message)?;
|
|
||||||
self.handle_local_call(header, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receive_data(
|
|
||||||
&mut self,
|
|
||||||
parsed: crate::protocol::ParsedFrame<'_>,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let (header, payload) = parsed.into_parts();
|
|
||||||
let message = deserialize_archived_bytes::<
|
|
||||||
ArchivedDataMessage,
|
|
||||||
crate::protocol::DataMessage,
|
|
||||||
>(payload)?;
|
|
||||||
self.handle_local_data(header, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receive_fault(
|
|
||||||
&mut self,
|
|
||||||
parsed: crate::protocol::ParsedFrame<'_>,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let (header, payload) = parsed.into_parts();
|
|
||||||
let message = deserialize_archived_bytes::<
|
|
||||||
ArchivedFaultMessage,
|
|
||||||
crate::protocol::FaultMessage,
|
|
||||||
>(payload)?;
|
|
||||||
self.handle_local_fault(header, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn forward_or_drop(
|
|
||||||
route: RouteDecision,
|
|
||||||
frame: crate::protocol::FrameBytes,
|
|
||||||
) -> EndpointOutcome {
|
|
||||||
match route {
|
|
||||||
RouteDecision::Child(index) => EndpointOutcome::Forward {
|
|
||||||
route: RouteDecision::Child(index),
|
|
||||||
frame,
|
|
||||||
},
|
|
||||||
RouteDecision::Parent => EndpointOutcome::Forward {
|
|
||||||
route: RouteDecision::Parent,
|
|
||||||
frame,
|
|
||||||
},
|
|
||||||
RouteDecision::Drop => EndpointOutcome::Dropped,
|
|
||||||
RouteDecision::Local => unreachable!("local routes are handled before forwarding"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Endpoint for ProtocolEndpoint {
|
|
||||||
fn path(&self) -> &[alloc::string::String] {
|
|
||||||
&self.path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn receive(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
frame: crate::protocol::FrameBytes,
|
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
|
||||||
let parsed = decode_frame(&frame)?;
|
|
||||||
let header = parsed.header();
|
|
||||||
validate_header(header)?;
|
|
||||||
|
|
||||||
if !self.valid_source_for_ingress(ingress, &header.src_path) {
|
|
||||||
return Ok(EndpointOutcome::Dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
let route = self.decide_route(&header.dst_path);
|
|
||||||
if route != RouteDecision::Local {
|
|
||||||
return Ok(Self::forward_or_drop(route, frame));
|
|
||||||
}
|
|
||||||
|
|
||||||
match header.packet_type {
|
|
||||||
crate::protocol::PacketType::Call => self.receive_call(ingress, parsed),
|
|
||||||
crate::protocol::PacketType::Data => self.receive_data(parsed),
|
|
||||||
crate::protocol::PacketType::Fault => self.receive_fault(parsed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
//! Hook state for pending and active protocol flows.
|
|
||||||
//!
|
|
||||||
//! Hooks move through two phases:
|
|
||||||
//! - `PendingHook` tracks enough context to attribute faults before the callee accepts.
|
|
||||||
//! - `ActiveHook` tracks the live bidirectional flow after activation.
|
|
||||||
//!
|
|
||||||
//! The table indexes active hooks both by their host-side return path and by the remote
|
|
||||||
//! peer path so routing code can resolve whichever side of the relationship it currently has.
|
|
||||||
//! The `HookKey` already carries the host path and hook id, so the pending/active records only
|
|
||||||
//! store the extra state that actually changes across the hook lifecycle.
|
|
||||||
|
|
||||||
use alloc::{collections::BTreeMap, string::String, vec::Vec};
|
|
||||||
|
|
||||||
/// Hook table key scoped to the hook host path.
|
|
||||||
///
|
|
||||||
/// This exists because hook ids are only unique relative to the endpoint path that hosts the
|
|
||||||
/// hook state.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookKey;
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 7);
|
|
||||||
/// assert_eq!(key.hook_id, 7);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct HookKey {
|
|
||||||
/// Path of the endpoint hosting the hook state.
|
|
||||||
pub return_path: Vec<String>,
|
|
||||||
/// Per-host hook identifier.
|
|
||||||
pub hook_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HookKey {
|
|
||||||
/// Builds the canonical key for a hook hosted at `return_path`.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookKey;
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 42);
|
|
||||||
/// assert_eq!(key.return_path, vec![String::from("root")]);
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
return_path,
|
|
||||||
hook_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pending hook context used only for fault attribution before activation.
|
|
||||||
///
|
|
||||||
/// This exists so outbound calls can reserve response-hook ownership before the callee has sent
|
|
||||||
/// its first valid `Data` packet.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::PendingHook;
|
|
||||||
/// let pending = PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// };
|
|
||||||
/// assert!(!pending.local_ended);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct PendingHook {
|
|
||||||
/// Caller path to promote into `peer_path` once the hook becomes active.
|
|
||||||
pub caller_src_path: Vec<String>,
|
|
||||||
/// Procedure that created the hook.
|
|
||||||
pub procedure_id: String,
|
|
||||||
/// Set once the local side has already emitted its terminal message before activation.
|
|
||||||
pub local_ended: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active hook context used for ordinary data traffic.
|
|
||||||
///
|
|
||||||
/// This exists once one peer has proven ownership of the hook stream and ordinary `Data`/`Fault`
|
|
||||||
/// routing can proceed without the pending reservation state.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ActiveHook;
|
|
||||||
/// let active = ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(active.peer_path[0], "worker");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ActiveHook {
|
|
||||||
/// Remote endpoint path currently paired with this hook.
|
|
||||||
pub peer_path: Vec<String>,
|
|
||||||
/// Procedure that owns the hook conversation.
|
|
||||||
pub procedure_id: String,
|
|
||||||
/// Set once the local side has emitted its terminal message.
|
|
||||||
pub local_ended: bool,
|
|
||||||
/// Set once the peer side has emitted its terminal message.
|
|
||||||
pub peer_ended: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duplicate hook insertion error.
|
|
||||||
///
|
|
||||||
/// This exists so callers can distinguish “hook id already reserved” from other runtime errors.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookConflict;
|
|
||||||
/// let _conflict = HookConflict;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct HookConflict;
|
|
||||||
|
|
||||||
/// Durable hook state tables.
|
|
||||||
///
|
|
||||||
/// This owns both pending and active hook lifecycle state plus a peer-path index for resolving
|
|
||||||
/// inbound hook traffic from either side of the conversation.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_pending(key.clone(), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// }).unwrap();
|
|
||||||
/// assert_eq!(hooks.pending_len(), 1);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct HookTable {
|
|
||||||
pending: BTreeMap<HookKey, PendingHook>,
|
|
||||||
active: BTreeMap<HookKey, ActiveHook>,
|
|
||||||
active_by_peer: BTreeMap<u64, BTreeMap<Vec<String>, HookKey>>,
|
|
||||||
next_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HookTable {
|
|
||||||
/// Allocates a non-zero hook id for a hook hosted at `return_path`.
|
|
||||||
///
|
|
||||||
/// Hook ids are scoped by host path, so this only needs to guarantee uniqueness within the
|
|
||||||
/// local table. The wrapped increment keeps allocation infallible for long-lived runtimes.
|
|
||||||
///
|
|
||||||
/// The table currently uses one counter shared across all host paths. The `return_path`
|
|
||||||
/// parameter remains in the API because hook ids are still interpreted as host-scoped by the
|
|
||||||
/// rest of the protocol surface.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookTable;
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let id = hooks.allocate_hook_id(&[String::from("root")]);
|
|
||||||
/// assert_ne!(id, 0);
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 {
|
|
||||||
let id = self.next_id.max(1);
|
|
||||||
self.next_id = id.wrapping_add(1);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts a hook that has been announced but not yet accepted by the callee.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// hooks.insert_pending(HookKey::new(vec!["root".into()], 1), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn insert_pending(
|
|
||||||
&mut self,
|
|
||||||
key: HookKey,
|
|
||||||
pending: PendingHook,
|
|
||||||
) -> Result<(), HookConflict> {
|
|
||||||
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
|
|
||||||
return Err(HookConflict);
|
|
||||||
}
|
|
||||||
self.pending.insert(key, pending);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Promotes a pending hook into the active table.
|
|
||||||
///
|
|
||||||
/// Activation intentionally reuses the original hook id and host path, but swaps the
|
|
||||||
/// pending caller attribution into the active peer path used for data routing.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_pending(key.clone(), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// hooks.activate_pending(&key);
|
|
||||||
/// assert_eq!(hooks.active_len(), 1);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn activate_pending(&mut self, key: &HookKey) -> Option<()> {
|
|
||||||
let pending = self.pending.remove(key)?;
|
|
||||||
self.insert_active(
|
|
||||||
key.clone(),
|
|
||||||
ActiveHook {
|
|
||||||
peer_path: pending.caller_src_path,
|
|
||||||
procedure_id: pending.procedure_id,
|
|
||||||
local_ended: pending.local_ended,
|
|
||||||
peer_ended: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts a live hook and its peer-path lookup entry.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// hooks.insert_active(HookKey::new(vec!["root".into()], 1), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert_eq!(hooks.active_len(), 1);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn insert_active(&mut self, key: HookKey, active: ActiveHook) -> Result<(), HookConflict> {
|
|
||||||
// Reject both duplicate host-scoped keys and duplicate peer ownership claims. Either one
|
|
||||||
// would make later inbound hook traffic ambiguous.
|
|
||||||
if self.pending.contains_key(&key)
|
|
||||||
|| self.active.contains_key(&key)
|
|
||||||
|| self
|
|
||||||
.active_by_peer
|
|
||||||
.get(&key.hook_id)
|
|
||||||
.is_some_and(|peer_paths| peer_paths.contains_key(active.peer_path.as_slice()))
|
|
||||||
{
|
|
||||||
return Err(HookConflict);
|
|
||||||
}
|
|
||||||
self.active_by_peer
|
|
||||||
.entry(key.hook_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(active.peer_path.clone(), key.clone());
|
|
||||||
self.active.insert(key, active);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a pending hook without affecting active state.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_pending(key.clone(), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.remove_pending(&key).is_some());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
|
|
||||||
self.pending.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the local side finished before the hook becomes active.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_pending(key.clone(), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// hooks.mark_pending_local_end(&key);
|
|
||||||
/// assert!(hooks.pending(&key).unwrap().local_ended);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn mark_pending_local_end(&mut self, key: &HookKey) {
|
|
||||||
if let Some(pending) = self.pending.get_mut(key) {
|
|
||||||
pending.local_ended = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes an active hook and its secondary peer-path index entry.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.remove_active(&key).is_some());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
|
|
||||||
let active = self.active.remove(key)?;
|
|
||||||
if let Some(peer_paths) = self.active_by_peer.get_mut(&key.hook_id) {
|
|
||||||
peer_paths.remove(active.peer_path.as_slice());
|
|
||||||
if peer_paths.is_empty() {
|
|
||||||
self.active_by_peer.remove(&key.hook_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(active)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the pending hook for `key`, if present.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_pending(key.clone(), PendingHook {
|
|
||||||
/// caller_src_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.pending(&key).is_some());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
|
|
||||||
self.pending.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the active hook for `key`, if present.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.active(&key).is_some());
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
|
|
||||||
self.active.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the mutable active hook for `key`, if present.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// hooks.active_mut(&key).unwrap().peer_ended = true;
|
|
||||||
/// assert!(hooks.active(&key).unwrap().peer_ended);
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
|
|
||||||
self.active.get_mut(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves an active hook from either side of the conversation.
|
|
||||||
///
|
|
||||||
/// The host side addresses hooks directly by `(return_path, hook_id)`. Peer-originated
|
|
||||||
/// traffic only has `(hook_id, peer_path)`, so the secondary index maps that back to the
|
|
||||||
/// canonical host-scoped key.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert_eq!(hooks.resolve_active_key(&["root".into()], 1, &["worker".into()]), Some(key));
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_active_key(
|
|
||||||
&self,
|
|
||||||
return_path: &[String],
|
|
||||||
hook_id: u64,
|
|
||||||
peer_path: &[String],
|
|
||||||
) -> Option<HookKey> {
|
|
||||||
// Prefer peer-originated resolution first because inbound hook traffic normally arrives
|
|
||||||
// from the far side with only `(hook_id, peer_path)` available.
|
|
||||||
if let Some(key) = self
|
|
||||||
.active_by_peer
|
|
||||||
.get(&hook_id)
|
|
||||||
.and_then(|peer_paths| peer_paths.get(peer_path))
|
|
||||||
{
|
|
||||||
return Some(key.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let host_key = HookKey::new(return_path.to_vec(), hook_id);
|
|
||||||
self.active.contains_key(&host_key).then_some(host_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the local side finished and returns `true` once both sides are finished.
|
|
||||||
///
|
|
||||||
/// This does not remove the hook. Callers use the boolean to decide whether cleanup should
|
|
||||||
/// happen immediately or whether the peer side is still expected to send more traffic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: false,
|
|
||||||
/// peer_ended: true,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.mark_local_end(&key));
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn mark_local_end(&mut self, key: &HookKey) -> bool {
|
|
||||||
let Some(active) = self.active_mut(key) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
active.local_ended = true;
|
|
||||||
active.peer_ended
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the peer side finished and returns `true` once both sides are finished.
|
|
||||||
///
|
|
||||||
/// This mirrors [`mark_local_end`](Self::mark_local_end): it only reports completion, leaving
|
|
||||||
/// final removal to the caller so higher layers can decide when to tear down hook state.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
|
|
||||||
/// let mut hooks = HookTable::default();
|
|
||||||
/// let key = HookKey::new(vec!["root".into()], 1);
|
|
||||||
/// hooks.insert_active(key.clone(), ActiveHook {
|
|
||||||
/// peer_path: vec!["worker".into()],
|
|
||||||
/// procedure_id: "example.service.v1.invoke".into(),
|
|
||||||
/// local_ended: true,
|
|
||||||
/// peer_ended: false,
|
|
||||||
/// })?;
|
|
||||||
/// assert!(hooks.mark_peer_end(&key));
|
|
||||||
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
|
|
||||||
/// ```
|
|
||||||
pub fn mark_peer_end(&mut self, key: &HookKey) -> bool {
|
|
||||||
let Some(active) = self.active_mut(key) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
active.peer_ended = true;
|
|
||||||
active.local_ended
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of active hooks.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookTable;
|
|
||||||
/// let hooks = HookTable::default();
|
|
||||||
/// assert_eq!(hooks.active_len(), 0);
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn active_len(&self) -> usize {
|
|
||||||
self.active.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of pending hooks.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::HookTable;
|
|
||||||
/// let hooks = HookTable::default();
|
|
||||||
/// assert_eq!(hooks.pending_len(), 0);
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn pending_len(&self) -> usize {
|
|
||||||
self.pending.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
//! Application-facing leaf metadata helpers.
|
|
||||||
//!
|
|
||||||
//! The protocol runtime itself only knows about `LeafSpec` metadata and validated
|
|
||||||
//! `LocalEvent` delivery. `ProtocolLeaf` owns canonical identity, `LeafDeclaration`
|
|
||||||
//! owns the compile-time procedure inventory for one leaf surface, and
|
|
||||||
//! `CallProcedures` adds local call dispatch on top of that inventory.
|
|
||||||
|
|
||||||
use alloc::{string::String, vec::Vec};
|
|
||||||
|
|
||||||
use crate::protocol::FrameBytes;
|
|
||||||
|
|
||||||
use super::{ChildRoute, LeafSpec, ProtocolEndpoint};
|
|
||||||
|
|
||||||
/// Static metadata for one application-defined protocol leaf.
|
|
||||||
///
|
|
||||||
/// This exists so runtime code can ask one type for its canonical dotted leaf id without knowing
|
|
||||||
/// any of that leaf's call-dispatch details.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolLeaf;
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(ExampleLeaf::leaf_name(), "org.example.v1.echo");
|
|
||||||
/// ```
|
|
||||||
pub trait ProtocolLeaf {
|
|
||||||
/// Returns the canonical dotted leaf name hosted by this type.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProtocolLeaf;
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// assert!(ExampleLeaf::leaf_name().starts_with("org.example"));
|
|
||||||
/// ```
|
|
||||||
fn leaf_name() -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compile-time declaration metadata for one leaf surface.
|
|
||||||
///
|
|
||||||
/// What it is: a trait for types that can describe the complete protocol-visible
|
|
||||||
/// surface of one leaf at compile time.
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint construction should not need handwritten procedure
|
|
||||||
/// lists. A leaf declaration can generate the canonical suffix inventory once and
|
|
||||||
/// let both endpoint and TUI host types reuse it.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// impl LeafDeclaration for ExampleLeaf {
|
|
||||||
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(ExampleLeaf::leaf_spec().procedures, vec![String::from("org.example.v1.echo.invoke")]);
|
|
||||||
/// ```
|
|
||||||
pub trait LeafDeclaration: ProtocolLeaf {
|
|
||||||
/// Returns the local procedure suffixes supported by this leaf.
|
|
||||||
fn procedure_suffixes() -> &'static [&'static str];
|
|
||||||
|
|
||||||
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
|
|
||||||
fn procedure_id(suffix: &str) -> Option<String> {
|
|
||||||
if !Self::procedure_suffixes().contains(&suffix) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut procedure_id = Self::leaf_name();
|
|
||||||
procedure_id.push('.');
|
|
||||||
procedure_id.push_str(suffix);
|
|
||||||
Some(procedure_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the full canonical `procedure_id` values supported by this leaf.
|
|
||||||
fn procedure_ids() -> Vec<String> {
|
|
||||||
Self::procedure_suffixes()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|suffix| Self::procedure_id(suffix))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`.
|
|
||||||
fn leaf_spec() -> LeafSpec {
|
|
||||||
LeafSpec {
|
|
||||||
name: Self::leaf_name(),
|
|
||||||
procedures: Self::procedure_ids(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the canonical `LeafSpec` for one concrete leaf host value.
|
|
||||||
///
|
|
||||||
/// What it is: a tiny typed helper that uses a host value only for type inference.
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint-construction macros can accept ordinary host expressions like
|
|
||||||
/// `RemoteShell::default()` and still derive the compile-time `LeafSpec` without the caller
|
|
||||||
/// spelling the leaf type twice.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf, leaf_spec_of};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// impl LeafDeclaration for ExampleLeaf {
|
|
||||||
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
|
|
||||||
/// }
|
|
||||||
/// let spec = leaf_spec_of(&ExampleLeaf);
|
|
||||||
/// assert_eq!(spec.name, "org.example.v1.echo");
|
|
||||||
/// ```
|
|
||||||
pub fn leaf_spec_of<L>(_: &L) -> LeafSpec
|
|
||||||
where
|
|
||||||
L: LeafDeclaration,
|
|
||||||
{
|
|
||||||
L::leaf_spec()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Declares that one host struct is bound to one compile-time leaf declaration.
|
|
||||||
///
|
|
||||||
/// What it is: a trait that links a concrete host type, such as an endpoint or
|
|
||||||
/// TUI struct, back to the declaration that owns its shared protocol metadata.
|
|
||||||
///
|
|
||||||
/// Why it exists: endpoint and TUI hosts often need different state and behavior,
|
|
||||||
/// but they should still share one canonical leaf identity and procedure list.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafBinding, LeafDeclaration, ProtocolLeaf};
|
|
||||||
/// struct ExampleDecl;
|
|
||||||
/// impl ProtocolLeaf for ExampleDecl {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// impl LeafDeclaration for ExampleDecl {
|
|
||||||
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
|
|
||||||
/// }
|
|
||||||
/// struct ExampleHost;
|
|
||||||
/// impl ProtocolLeaf for ExampleHost {
|
|
||||||
/// fn leaf_name() -> String { ExampleDecl::leaf_name() }
|
|
||||||
/// }
|
|
||||||
/// impl LeafBinding for ExampleHost {
|
|
||||||
/// type Declaration = ExampleDecl;
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(<ExampleHost as LeafBinding>::Declaration::leaf_name(), "org.example.v1.echo");
|
|
||||||
/// ```
|
|
||||||
pub trait LeafBinding: ProtocolLeaf {
|
|
||||||
/// Shared declaration that owns the canonical metadata for this host type.
|
|
||||||
type Declaration: ProtocolLeaf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generated call metadata and initial `Call` dispatch for one leaf.
|
|
||||||
///
|
|
||||||
/// This exists so one leaf type can advertise which procedure suffixes it serves and convert an
|
|
||||||
/// opening protocol `Call` into leaf-local behavior.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{CallProcedures, DispatchError, IncomingCall, ProtocolLeaf};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
|
|
||||||
/// }
|
|
||||||
/// impl CallProcedures for ExampleLeaf {
|
|
||||||
/// type Error = core::convert::Infallible;
|
|
||||||
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
|
|
||||||
/// fn dispatch_call(&mut self, _endpoint: &mut unshell::protocol::tree::ProtocolEndpoint, _call: IncomingCall) -> Result<unshell::protocol::tree::CallReply, DispatchError<Self::Error>> {
|
|
||||||
/// Ok(unshell::protocol::tree::CallReply::NoReply)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke");
|
|
||||||
/// ```
|
|
||||||
pub trait CallProcedures: LeafDeclaration {
|
|
||||||
/// Leaf-specific error surfaced when generated call dispatch fails.
|
|
||||||
type Error;
|
|
||||||
|
|
||||||
/// Dispatches one initial `Call` that targeted this leaf.
|
|
||||||
///
|
|
||||||
/// Implementations may assume the endpoint already proved the call targets this leaf.
|
|
||||||
/// They are still responsible for decoding the typed input payload and deciding which local
|
|
||||||
/// procedure suffix should run.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{CallProcedures, DispatchError, IncomingCall, ProtocolLeaf};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
|
|
||||||
/// impl CallProcedures for ExampleLeaf {
|
|
||||||
/// type Error = core::convert::Infallible;
|
|
||||||
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
|
|
||||||
/// fn dispatch_call(&mut self, _endpoint: &mut unshell::protocol::tree::ProtocolEndpoint, _call: IncomingCall) -> Result<unshell::protocol::tree::CallReply, DispatchError<Self::Error>> {
|
|
||||||
/// Ok(unshell::protocol::tree::CallReply::NoReply)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// # let _ = ExampleLeaf;
|
|
||||||
/// ```
|
|
||||||
fn dispatch_call(
|
|
||||||
&mut self,
|
|
||||||
endpoint: &mut ProtocolEndpoint,
|
|
||||||
call: crate::protocol::tree::IncomingCall,
|
|
||||||
) -> Result<crate::protocol::tree::CallReply, crate::protocol::tree::DispatchError<Self::Error>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Router-facing transport hooks for leaves that own parent/child connections.
|
|
||||||
///
|
|
||||||
/// What it is: an opt-in trait for leaves that want to act as the transport layer
|
|
||||||
/// for one endpoint's forwarded traffic.
|
|
||||||
///
|
|
||||||
/// Why it exists: ordinary leaves only need validated local events, but a router
|
|
||||||
/// leaf also needs to know its active parent/children and where to physically send
|
|
||||||
/// frames chosen by the endpoint's routing logic.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::FrameBytes;
|
|
||||||
/// use unshell::protocol::tree::{ChildRoute, RouterLeaf};
|
|
||||||
/// #[derive(Default)]
|
|
||||||
/// struct DemoRouter {
|
|
||||||
/// parent: Option<Vec<String>>,
|
|
||||||
/// children: Vec<ChildRoute>,
|
|
||||||
/// }
|
|
||||||
/// impl unshell::protocol::tree::ProtocolLeaf for DemoRouter {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.router".into() }
|
|
||||||
/// }
|
|
||||||
/// impl RouterLeaf for DemoRouter {
|
|
||||||
/// type RouteError = core::convert::Infallible;
|
|
||||||
///
|
|
||||||
/// fn parent_path(&self) -> Option<&[String]> { self.parent.as_deref() }
|
|
||||||
/// fn child_routes(&self) -> &[ChildRoute] { &self.children }
|
|
||||||
/// fn route_to_parent(&mut self, _local_path: &[String], _frame: FrameBytes) -> Result<(), Self::RouteError> { Ok(()) }
|
|
||||||
/// fn route_to_child(&mut self, _child_path: &[String], _frame: FrameBytes) -> Result<(), Self::RouteError> { Ok(()) }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait RouterLeaf: ProtocolLeaf {
|
|
||||||
/// Transport-specific error surfaced while handing a frame to the chosen link.
|
|
||||||
type RouteError;
|
|
||||||
|
|
||||||
/// Returns the currently connected direct parent path, if any.
|
|
||||||
fn parent_path(&self) -> Option<&[String]>;
|
|
||||||
|
|
||||||
/// Returns the currently connected direct child routes.
|
|
||||||
fn child_routes(&self) -> &[ChildRoute];
|
|
||||||
|
|
||||||
/// Sends one routed frame toward the direct parent connection.
|
|
||||||
fn route_to_parent(
|
|
||||||
&mut self,
|
|
||||||
local_path: &[String],
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<(), Self::RouteError>;
|
|
||||||
|
|
||||||
/// Sends one routed frame toward the chosen direct child connection.
|
|
||||||
fn route_to_child(
|
|
||||||
&mut self,
|
|
||||||
child_path: &[String],
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<(), Self::RouteError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds one canonical dotted leaf id from crate-local metadata plus optional
|
|
||||||
/// user overrides.
|
|
||||||
///
|
|
||||||
/// Rationale: derive macros cannot reliably inspect Cargo workspace metadata, but
|
|
||||||
/// they can always access the current package name, module path, crate version,
|
|
||||||
/// and Rust type name at the expansion site. This helper normalizes those inputs
|
|
||||||
/// into one deterministic dotted identifier without leaking Rust separators or
|
|
||||||
/// casing into protocol-visible names. Deterministic is not the same as stable
|
|
||||||
/// across refactors, so shipped protocol surfaces should prefer explicit `id`
|
|
||||||
/// overrides.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::derive_leaf_name;
|
|
||||||
///
|
|
||||||
/// let leaf = derive_leaf_name(
|
|
||||||
/// "unshell-core",
|
|
||||||
/// "0",
|
|
||||||
/// "1",
|
|
||||||
/// "0",
|
|
||||||
/// "unshell_core::examples::demo_shell",
|
|
||||||
/// "ShellLeaf",
|
|
||||||
/// None,
|
|
||||||
/// None,
|
|
||||||
/// None,
|
|
||||||
/// None,
|
|
||||||
/// None,
|
|
||||||
/// );
|
|
||||||
/// assert_eq!(leaf, "unshell_core.unshell_core.v0_1_0.examples.demo_shell.shell_leaf");
|
|
||||||
/// ```
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
// This helper mirrors derive-macro inputs directly so callers do not have to allocate an
|
|
||||||
// intermediate metadata struct just to compute one deterministic protocol identifier.
|
|
||||||
pub fn derive_leaf_name(
|
|
||||||
package_name: &str,
|
|
||||||
version_major: &str,
|
|
||||||
version_minor: &str,
|
|
||||||
version_patch: &str,
|
|
||||||
module_path: &str,
|
|
||||||
type_name: &str,
|
|
||||||
org: Option<&str>,
|
|
||||||
product: Option<&str>,
|
|
||||||
version: Option<&str>,
|
|
||||||
leaf_name: Option<&str>,
|
|
||||||
id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
if let Some(id) = id.filter(|value| !value.is_empty()) {
|
|
||||||
return String::from(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let package_segment = normalize_leaf_segment(package_name);
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
segments.push(normalize_leaf_segment(org.unwrap_or(package_name)));
|
|
||||||
segments.push(normalize_leaf_segment(product.unwrap_or(package_name)));
|
|
||||||
segments.push(normalize_version_segment(version.unwrap_or(
|
|
||||||
&alloc::format!("v{}_{}_{}", version_major, version_minor, version_patch),
|
|
||||||
)));
|
|
||||||
|
|
||||||
if let Some(leaf_name) = leaf_name.filter(|value| !value.is_empty()) {
|
|
||||||
segments.extend(split_leaf_path(leaf_name));
|
|
||||||
} else {
|
|
||||||
// The package-derived prefix already names the crate/product portion of the identifier, so
|
|
||||||
// strip the same leading segment from `module_path` when it would otherwise duplicate it.
|
|
||||||
let mut module_segments = module_path
|
|
||||||
.split("::")
|
|
||||||
.map(normalize_leaf_segment)
|
|
||||||
.filter(|segment| !segment.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if module_segments
|
|
||||||
.first()
|
|
||||||
.is_some_and(|segment| segment == &package_segment)
|
|
||||||
{
|
|
||||||
module_segments.remove(0);
|
|
||||||
}
|
|
||||||
segments.extend(module_segments);
|
|
||||||
segments.push(normalize_leaf_segment(type_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.join(".")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_leaf_path(value: &str) -> Vec<String> {
|
|
||||||
value
|
|
||||||
.split('.')
|
|
||||||
.map(normalize_leaf_segment)
|
|
||||||
.filter(|segment| !segment.is_empty())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_version_segment(value: &str) -> String {
|
|
||||||
let normalized = normalize_leaf_segment(value);
|
|
||||||
if normalized.starts_with('v') && normalized.len() > 1 {
|
|
||||||
normalized
|
|
||||||
} else {
|
|
||||||
alloc::format!("v{}", normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_leaf_segment(value: &str) -> String {
|
|
||||||
let mut normalized = String::with_capacity(value.len());
|
|
||||||
let mut previous_was_separator = false;
|
|
||||||
|
|
||||||
for character in value.chars() {
|
|
||||||
if character.is_ascii_uppercase() {
|
|
||||||
// Preserve CamelCase word boundaries in a snake_case protocol identifier.
|
|
||||||
if !normalized.is_empty() && !previous_was_separator {
|
|
||||||
normalized.push('_');
|
|
||||||
}
|
|
||||||
normalized.push(character.to_ascii_lowercase());
|
|
||||||
previous_was_separator = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if character.is_ascii_lowercase() || character.is_ascii_digit() {
|
|
||||||
normalized.push(character);
|
|
||||||
previous_was_separator = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !normalized.is_empty() && !previous_was_separator {
|
|
||||||
normalized.push('_');
|
|
||||||
previous_was_separator = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while normalized.ends_with('_') {
|
|
||||||
normalized.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalized.is_empty() {
|
|
||||||
// Protocol identifiers still need a stable non-empty placeholder when user input is all
|
|
||||||
// punctuation or whitespace.
|
|
||||||
String::from("leaf")
|
|
||||||
} else {
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use alloc::string::String;
|
|
||||||
|
|
||||||
use super::{LeafBinding, LeafDeclaration, ProtocolLeaf, derive_leaf_name};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_leaf_name_normalizes_inputs_into_dotted_segments() {
|
|
||||||
assert_eq!(
|
|
||||||
derive_leaf_name(
|
|
||||||
"unshell-core",
|
|
||||||
"0",
|
|
||||||
"1",
|
|
||||||
"0",
|
|
||||||
"unshell_core::examples::demo_shell",
|
|
||||||
"ShellLeaf",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"unshell_core.unshell_core.v0_1_0.examples.demo_shell.shell_leaf"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_leaf_name_applies_partial_overrides() {
|
|
||||||
assert_eq!(
|
|
||||||
derive_leaf_name(
|
|
||||||
"unshell-core",
|
|
||||||
"0",
|
|
||||||
"1",
|
|
||||||
"0",
|
|
||||||
"unshell_core::examples::demo_shell",
|
|
||||||
"ShellLeaf",
|
|
||||||
Some("org"),
|
|
||||||
Some("product"),
|
|
||||||
Some("v1.2.3.4"),
|
|
||||||
Some("echo.shell"),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
"org.product.v1_2_3_4.echo.shell"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_leaf_name_id_override_wins() {
|
|
||||||
assert_eq!(
|
|
||||||
derive_leaf_name(
|
|
||||||
"unshell-core",
|
|
||||||
"0",
|
|
||||||
"1",
|
|
||||||
"0",
|
|
||||||
"unshell_core::examples::demo_shell",
|
|
||||||
"ShellLeaf",
|
|
||||||
Some("org"),
|
|
||||||
Some("product"),
|
|
||||||
Some("v1"),
|
|
||||||
Some("echo"),
|
|
||||||
Some("org.example.v1.echo.abc"),
|
|
||||||
),
|
|
||||||
"org.example.v1.echo.abc"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bound_hosts_can_share_one_declaration() {
|
|
||||||
struct SharedDecl;
|
|
||||||
impl ProtocolLeaf for SharedDecl {
|
|
||||||
fn leaf_name() -> String {
|
|
||||||
String::from("org.example.v1.echo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl LeafDeclaration for SharedDecl {
|
|
||||||
fn procedure_suffixes() -> &'static [&'static str] {
|
|
||||||
&["invoke"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Host;
|
|
||||||
impl ProtocolLeaf for Host {
|
|
||||||
fn leaf_name() -> String {
|
|
||||||
SharedDecl::leaf_name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl LeafBinding for Host {
|
|
||||||
type Declaration = SharedDecl;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
<Host as LeafBinding>::Declaration::leaf_spec().name,
|
|
||||||
"org.example.v1.echo"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//! Explicit tree declaration, routing, and a small endpoint runtime.
|
|
||||||
//!
|
|
||||||
//! This module keeps the protocol tree machinery split by concern:
|
|
||||||
//! - `routing` contains static path declarations and longest-prefix routing helpers.
|
|
||||||
//! - `hook` contains the pending/active hook lifecycle tables used by endpoint runtime code.
|
|
||||||
//! - `endpoint` ties those pieces together into the runtime-facing protocol endpoint API.
|
|
||||||
//! - `leaf` defines application-facing metadata and generated call-dispatch traits.
|
|
||||||
//! - `call` and `procedure` layer higher-level runtimes on top of validated endpoint events.
|
|
||||||
|
|
||||||
mod call;
|
|
||||||
mod endpoint;
|
|
||||||
mod hook;
|
|
||||||
mod leaf;
|
|
||||||
mod procedure;
|
|
||||||
mod routing;
|
|
||||||
|
|
||||||
pub use call::{
|
|
||||||
Call, CallLeaf, CallReply, CallResult, DispatchError, IncomingCall, IncomingData,
|
|
||||||
IncomingFault, LeafRuntime, LeafRuntimeError, OutgoingData, RoutedRuntimeOutcome,
|
|
||||||
RuntimeOutcome, decode_call_input, encode_call_reply,
|
|
||||||
};
|
|
||||||
pub use endpoint::{
|
|
||||||
ChildRoute, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec, LocalEvent,
|
|
||||||
ProtocolEndpoint,
|
|
||||||
};
|
|
||||||
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
|
|
||||||
pub use leaf::{
|
|
||||||
CallProcedures, LeafBinding, LeafDeclaration, ProtocolLeaf, RouterLeaf, derive_leaf_name,
|
|
||||||
leaf_spec_of,
|
|
||||||
};
|
|
||||||
pub use procedure::{
|
|
||||||
Procedure, ProcedureEffect, ProcedureMetadata, ProcedureRuntime, ProcedureRuntimeError,
|
|
||||||
ProcedureRuntimeOutcome, ProcedureStore, StatefulProcedureMetadata,
|
|
||||||
};
|
|
||||||
pub use routing::{
|
|
||||||
CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode,
|
|
||||||
is_prefix, route_destination,
|
|
||||||
};
|
|
||||||
@@ -1,839 +0,0 @@
|
|||||||
//! Procedure-scoped session runtime for complex hook-backed leaves.
|
|
||||||
//!
|
|
||||||
//! This layer exists for procedures that need long-lived per-hook state, such as
|
|
||||||
//! a remote shell. The leaf owns the session table explicitly, while the runtime
|
|
||||||
//! handles the protocol bookkeeping around initial `Call`, follow-on `Data`, and
|
|
||||||
//! upstream `Fault` traffic.
|
|
||||||
//!
|
|
||||||
//! # Model
|
|
||||||
//!
|
|
||||||
//! - One opening `Call` targets one procedure suffix such as `open`.
|
|
||||||
//! - If that procedure succeeds, it returns one session value.
|
|
||||||
//! - The runtime stores that session under the hook key declared by the caller.
|
|
||||||
//! - Later hook traffic is routed back to that same session automatically.
|
|
||||||
//!
|
|
||||||
//! The protocol still owns transport truth such as half-close state and fault
|
|
||||||
//! routing. Procedure sessions only own application resources and behavior.
|
|
||||||
|
|
||||||
use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
|
|
||||||
use core::{fmt, marker::PhantomData};
|
|
||||||
|
|
||||||
use rkyv::{Archive, rancor::Error};
|
|
||||||
|
|
||||||
use crate::protocol::{CallMessage, FrameBytes, HookTarget, ProtocolFault};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
DispatchError, Endpoint, EndpointError, HookKey, IncomingData, IncomingFault, Ingress,
|
|
||||||
LocalEvent, OutgoingData, ProtocolEndpoint, ProtocolLeaf, decode_call_input,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Canonical compile-time metadata for one procedure surface.
|
|
||||||
///
|
|
||||||
/// What it is: a trait that defines the leaf type and local suffix used to derive
|
|
||||||
/// one stable protocol `procedure_id`.
|
|
||||||
///
|
|
||||||
/// Why it exists: compile-time leaf declarations and future typed remote methods
|
|
||||||
/// need to talk about procedures without hand-assembling identifiers at each use
|
|
||||||
/// site.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
|
|
||||||
/// }
|
|
||||||
/// struct Open;
|
|
||||||
/// impl ProcedureMetadata for Open {
|
|
||||||
/// type Leaf = ExampleLeaf;
|
|
||||||
/// const PROCEDURE_SUFFIX: &'static str = "open";
|
|
||||||
/// }
|
|
||||||
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
|
|
||||||
/// ```
|
|
||||||
pub trait ProcedureMetadata: Sized {
|
|
||||||
/// Leaf surface this procedure belongs to.
|
|
||||||
type Leaf: ProtocolLeaf;
|
|
||||||
|
|
||||||
/// Returns the local suffix used to derive the full canonical `procedure_id`.
|
|
||||||
const PROCEDURE_SUFFIX: &'static str;
|
|
||||||
|
|
||||||
/// Returns the local suffix used to derive the full canonical `procedure_id`.
|
|
||||||
fn procedure_suffix() -> &'static str {
|
|
||||||
Self::PROCEDURE_SUFFIX
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the canonical `procedure_id` for this procedure.
|
|
||||||
fn procedure_id() -> String {
|
|
||||||
let mut procedure_id = <Self::Leaf as ProtocolLeaf>::leaf_name();
|
|
||||||
procedure_id.push('.');
|
|
||||||
procedure_id.push_str(Self::procedure_suffix());
|
|
||||||
procedure_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generated metadata for one stateful procedure bound to one leaf type.
|
|
||||||
///
|
|
||||||
/// This metadata is intentionally tiny: one procedure suffix plus the derived
|
|
||||||
/// full `procedure_id`. The leaf still owns all session storage explicitly.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf, StatefulProcedureMetadata};
|
|
||||||
/// struct ExampleLeaf;
|
|
||||||
/// impl ProtocolLeaf for ExampleLeaf {
|
|
||||||
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
|
|
||||||
/// }
|
|
||||||
/// struct Open;
|
|
||||||
/// impl ProcedureMetadata for Open {
|
|
||||||
/// type Leaf = ExampleLeaf;
|
|
||||||
/// const PROCEDURE_SUFFIX: &'static str = "open";
|
|
||||||
/// }
|
|
||||||
/// fn _compat<T: StatefulProcedureMetadata<ExampleLeaf>>() {}
|
|
||||||
/// _compat::<Open>();
|
|
||||||
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
|
|
||||||
/// ```
|
|
||||||
pub trait StatefulProcedureMetadata<L>: ProcedureMetadata<Leaf = L> + Sized
|
|
||||||
where
|
|
||||||
L: ProtocolLeaf,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T, L> StatefulProcedureMetadata<L> for T
|
|
||||||
where
|
|
||||||
T: ProcedureMetadata<Leaf = L>,
|
|
||||||
L: ProtocolLeaf,
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Explicit storage access for one procedure session map inside the leaf.
|
|
||||||
///
|
|
||||||
/// Rationale: the leaf remains the source of truth for its active sessions. This
|
|
||||||
/// avoids hidden generated enums or side tables and keeps debugging obvious.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use std::collections::BTreeMap;
|
|
||||||
/// use unshell::protocol::tree::{HookKey, ProcedureStore};
|
|
||||||
/// struct Session;
|
|
||||||
/// struct Leaf { sessions: BTreeMap<HookKey, Session> }
|
|
||||||
/// impl ProcedureStore<Session> for Leaf {
|
|
||||||
/// fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Session> {
|
|
||||||
/// &mut self.sessions
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait ProcedureStore<P> {
|
|
||||||
/// Returns the hook-keyed session table for one procedure type.
|
|
||||||
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, P>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One procedure that owns per-hook session state.
|
|
||||||
///
|
|
||||||
/// The opening `Call` constructs one session value. The runtime then hands later
|
|
||||||
/// `Data`, `Fault`, and `poll()` ticks back to that stored session until the
|
|
||||||
/// session requests removal or the protocol faults it out.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use std::collections::BTreeMap;
|
|
||||||
/// use std::string::String;
|
|
||||||
/// use unshell::{Procedure, leaf};
|
|
||||||
/// use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
|
|
||||||
///
|
|
||||||
/// #[derive(Default)]
|
|
||||||
/// struct StreamLeaf {
|
|
||||||
/// sessions: BTreeMap<HookKey, OpenProcedure>,
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// leaf! {
|
|
||||||
/// id = "org.example.v1.stream",
|
|
||||||
/// procedures = [OpenProcedure],
|
|
||||||
/// endpoint_struct = StreamLeaf,
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// impl ProcedureStore<OpenProcedure> for StreamLeaf {
|
|
||||||
/// fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, OpenProcedure> {
|
|
||||||
/// &mut self.sessions
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// #[derive(Procedure)]
|
|
||||||
/// #[procedure(leaf = StreamLeaf, name = "open")]
|
|
||||||
/// struct OpenProcedure {
|
|
||||||
/// prefix: String,
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// impl Procedure<StreamLeaf> for OpenProcedure {
|
|
||||||
/// type Error = core::convert::Infallible;
|
|
||||||
/// type Input = String;
|
|
||||||
///
|
|
||||||
/// fn open(
|
|
||||||
/// _leaf: &mut StreamLeaf,
|
|
||||||
/// call: Call<Self::Input>,
|
|
||||||
/// ) -> Result<Self, Self::Error> {
|
|
||||||
/// Ok(Self { prefix: call.input })
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// fn poll(
|
|
||||||
/// _leaf: &mut StreamLeaf,
|
|
||||||
/// _session: &mut Self,
|
|
||||||
/// ) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
/// Ok(ProcedureEffect::default())
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait Procedure<L>: ProcedureMetadata<Leaf = L> + Sized
|
|
||||||
where
|
|
||||||
L: ProtocolLeaf,
|
|
||||||
{
|
|
||||||
/// Leaf-specific error surfaced while opening or advancing the session.
|
|
||||||
type Error;
|
|
||||||
/// Typed input payload decoded from the opening call.
|
|
||||||
type Input;
|
|
||||||
|
|
||||||
/// Creates one session from the opening `Call`.
|
|
||||||
fn open(leaf: &mut L, call: super::Call<Self::Input>) -> Result<Self, Self::Error>;
|
|
||||||
|
|
||||||
/// Handles one inbound hook `Data` packet for this procedure.
|
|
||||||
fn on_data(
|
|
||||||
_leaf: &mut L,
|
|
||||||
_session: &mut Self,
|
|
||||||
_data: IncomingData,
|
|
||||||
) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
Ok(ProcedureEffect::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles one inbound hook `Fault` packet for this procedure.
|
|
||||||
fn on_fault(
|
|
||||||
_leaf: &mut L,
|
|
||||||
_session: &mut Self,
|
|
||||||
_fault: IncomingFault,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls one live session for locally-generated hook traffic.
|
|
||||||
fn poll(_leaf: &mut L, _session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
|
|
||||||
Ok(ProcedureEffect::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Releases application resources when the runtime discards one session.
|
|
||||||
///
|
|
||||||
/// This hook exists because a runtime error may force the session to be
|
|
||||||
/// dropped before the normal protocol close path completes. Simple state
|
|
||||||
/// objects can keep the default no-op implementation.
|
|
||||||
fn close(_leaf: &mut L, _session: Self) -> Result<(), Self::Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Output produced while advancing one session.
|
|
||||||
///
|
|
||||||
/// This exists as the normalized result of one session step: some outgoing hook packets plus an
|
|
||||||
/// explicit decision about whether the session should stay alive.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProcedureEffect;
|
|
||||||
/// let effect = ProcedureEffect::close(Vec::new());
|
|
||||||
/// assert!(effect.close_session);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ProcedureEffect {
|
|
||||||
/// `Data` packets to emit after the session step completes.
|
|
||||||
pub outgoing: Vec<OutgoingData>,
|
|
||||||
/// Whether the runtime should remove the session after sending `outgoing`.
|
|
||||||
pub close_session: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureEffect {
|
|
||||||
/// Builds an effect that keeps the session alive after emitting `outgoing`.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProcedureEffect;
|
|
||||||
/// let effect = ProcedureEffect::outgoing(Vec::new());
|
|
||||||
/// assert!(!effect.close_session);
|
|
||||||
/// ```
|
|
||||||
pub fn outgoing(outgoing: Vec<OutgoingData>) -> Self {
|
|
||||||
Self {
|
|
||||||
outgoing,
|
|
||||||
close_session: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds an effect that closes the session after emitting `outgoing`.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProcedureEffect;
|
|
||||||
/// let effect = ProcedureEffect::close(Vec::new());
|
|
||||||
/// assert!(effect.close_session);
|
|
||||||
/// ```
|
|
||||||
pub fn close(outgoing: Vec<OutgoingData>) -> Self {
|
|
||||||
Self {
|
|
||||||
outgoing,
|
|
||||||
close_session: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error surfaced by the procedure runtime.
|
|
||||||
///
|
|
||||||
/// This exists so callers can tell apart transport/runtime failures from an opening call that
|
|
||||||
/// could not establish a procedure session.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::FrameError;
|
|
||||||
/// use unshell::protocol::tree::{DispatchError, ProcedureRuntimeError};
|
|
||||||
/// let error: ProcedureRuntimeError<core::convert::Infallible> =
|
|
||||||
/// ProcedureRuntimeError::Decode(DispatchError::Decode(FrameError::Truncated));
|
|
||||||
/// assert!(matches!(error, ProcedureRuntimeError::Decode(_)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ProcedureRuntimeError<E> {
|
|
||||||
/// Protocol endpoint routing or framing failed.
|
|
||||||
Endpoint(EndpointError),
|
|
||||||
/// The opening call failed to decode or open cleanly before a session existed.
|
|
||||||
///
|
|
||||||
/// Once a session is already live, runtime failures prefer emitting protocol faults and
|
|
||||||
/// tearing down that session rather than surfacing leaf errors directly.
|
|
||||||
Decode(super::DispatchError<E>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> fmt::Display for ProcedureRuntimeError<E>
|
|
||||||
where
|
|
||||||
E: fmt::Display,
|
|
||||||
{
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Endpoint(error) => write!(f, "{error}"),
|
|
||||||
Self::Decode(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E> core::error::Error for ProcedureRuntimeError<E> where E: core::error::Error + 'static {}
|
|
||||||
|
|
||||||
impl<E> From<EndpointError> for ProcedureRuntimeError<E> {
|
|
||||||
fn from(value: EndpointError) -> Self {
|
|
||||||
Self::Endpoint(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frames emitted while advancing one stateful procedure runtime.
|
|
||||||
///
|
|
||||||
/// This exists so callers can flush emitted frames to transport while also observing whether the
|
|
||||||
/// inbound packet was intentionally dropped.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProcedureRuntimeOutcome;
|
|
||||||
/// let outcome = ProcedureRuntimeOutcome::default();
|
|
||||||
/// assert!(outcome.frames.is_empty());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct ProcedureRuntimeOutcome {
|
|
||||||
/// Frames emitted while processing the current step.
|
|
||||||
pub frames: Vec<FrameBytes>,
|
|
||||||
/// Whether the endpoint dropped the incoming packet.
|
|
||||||
pub dropped: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime for one leaf paired with one procedure-owned session type.
|
|
||||||
///
|
|
||||||
/// This runtime is deliberately narrow. It is the right tool when one leaf owns
|
|
||||||
/// one hook-backed procedure whose session type is explicit in the leaf's state.
|
|
||||||
/// Simpler one-shot procedures can stay on [`crate::protocol::tree::LeafRuntime`].
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::ProcedureRuntime;
|
|
||||||
/// # struct Leaf;
|
|
||||||
/// # struct Proc;
|
|
||||||
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ProcedureRuntime<L, P> {
|
|
||||||
endpoint: ProtocolEndpoint,
|
|
||||||
leaf: L,
|
|
||||||
marker: PhantomData<P>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<L, P> ProcedureRuntime<L, P> {
|
|
||||||
/// Builds a procedure runtime from one endpoint and one leaf instance.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
/// struct Leaf;
|
|
||||||
/// struct Proc;
|
|
||||||
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(
|
|
||||||
/// ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()),
|
|
||||||
/// Leaf,
|
|
||||||
/// );
|
|
||||||
/// let _ = runtime;
|
|
||||||
/// ```
|
|
||||||
pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
|
|
||||||
Self {
|
|
||||||
endpoint,
|
|
||||||
leaf,
|
|
||||||
marker: PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the underlying protocol endpoint.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
/// struct Leaf;
|
|
||||||
/// struct Proc;
|
|
||||||
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
|
|
||||||
/// let _ = runtime.endpoint();
|
|
||||||
/// ```
|
|
||||||
pub fn endpoint(&self) -> &ProtocolEndpoint {
|
|
||||||
&self.endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a mutable reference to the protocol endpoint.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
/// struct Leaf;
|
|
||||||
/// struct Proc;
|
|
||||||
/// let mut runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
|
|
||||||
/// let _ = runtime.endpoint_mut();
|
|
||||||
/// ```
|
|
||||||
pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
|
|
||||||
&mut self.endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the hosted leaf instance.
|
|
||||||
#[must_use]
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
/// struct Leaf;
|
|
||||||
/// struct Proc;
|
|
||||||
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
|
|
||||||
/// let _ = runtime.leaf();
|
|
||||||
/// ```
|
|
||||||
pub fn leaf(&self) -> &L {
|
|
||||||
&self.leaf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a mutable reference to the hosted leaf instance.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
|
|
||||||
/// struct Leaf;
|
|
||||||
/// struct Proc;
|
|
||||||
/// let mut runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
|
|
||||||
/// let _ = runtime.leaf_mut();
|
|
||||||
/// ```
|
|
||||||
pub fn leaf_mut(&mut self) -> &mut L {
|
|
||||||
&mut self.leaf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<L, P> ProcedureRuntime<L, P>
|
|
||||||
where
|
|
||||||
L: ProtocolLeaf + ProcedureStore<P>,
|
|
||||||
P: Procedure<L>,
|
|
||||||
P::Input: Archive,
|
|
||||||
<P::Input as Archive>::Archived: rkyv::Portable
|
|
||||||
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>
|
|
||||||
+ rkyv::Deserialize<P::Input, rkyv::api::high::HighDeserializer<Error>>,
|
|
||||||
P::Error: fmt::Display,
|
|
||||||
{
|
|
||||||
/// Delivers one framed protocol packet into the runtime.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::ProcedureRuntime;
|
|
||||||
/// # struct Leaf;
|
|
||||||
/// # struct Proc;
|
|
||||||
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
|
|
||||||
/// ```
|
|
||||||
pub fn receive(
|
|
||||||
&mut self,
|
|
||||||
ingress: &Ingress,
|
|
||||||
frame: FrameBytes,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let outcome = self.endpoint.receive(ingress, frame)?;
|
|
||||||
self.process_endpoint_outcome(outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls all live sessions for locally-generated hook traffic.
|
|
||||||
///
|
|
||||||
/// Rationale: many long-lived procedures, including a remote shell, need to
|
|
||||||
/// emit output even when no new inbound protocol packet has arrived.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::ProcedureRuntime;
|
|
||||||
/// # struct Leaf;
|
|
||||||
/// # struct Proc;
|
|
||||||
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
|
|
||||||
/// ```
|
|
||||||
pub fn poll(&mut self) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let mut frames = Vec::new();
|
|
||||||
let keys = self
|
|
||||||
.leaf
|
|
||||||
.procedure_sessions()
|
|
||||||
.keys()
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for key in keys {
|
|
||||||
let Some(session) = self.leaf.procedure_sessions().remove(&key) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Collect keys first and temporarily remove each session so procedure callbacks can
|
|
||||||
// mutate the leaf without fighting the session-table borrow.
|
|
||||||
match self.poll_session(key, session)? {
|
|
||||||
Some(session_frames) => frames.extend(session_frames),
|
|
||||||
None => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames,
|
|
||||||
dropped: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_endpoint_outcome(
|
|
||||||
&mut self,
|
|
||||||
outcome: super::EndpointOutcome,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
match outcome {
|
|
||||||
super::EndpointOutcome::Forward { frame, .. } => Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames: vec![frame],
|
|
||||||
dropped: false,
|
|
||||||
}),
|
|
||||||
super::EndpointOutcome::Dropped => Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames: Vec::new(),
|
|
||||||
dropped: true,
|
|
||||||
}),
|
|
||||||
super::EndpointOutcome::Local(event) => self.process_local_event(event),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_session(
|
|
||||||
&mut self,
|
|
||||||
key: HookKey,
|
|
||||||
mut session: P,
|
|
||||||
) -> Result<Option<Vec<FrameBytes>>, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let effect = match P::poll(&mut self.leaf, &mut session) {
|
|
||||||
Ok(effect) => self.ensure_terminal_packet(&key, effect),
|
|
||||||
Err(error) => {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
let frames = self.emit_internal_fault(Some(key.clone()))?;
|
|
||||||
let _ = error;
|
|
||||||
return Ok(Some(frames));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let outgoing = match self.emit_outgoing(effect.outgoing) {
|
|
||||||
Ok(outgoing) => outgoing.frames,
|
|
||||||
Err(error) => {
|
|
||||||
// Emit failures are transport/runtime failures, not leaf-procedure failures. Keep
|
|
||||||
// the session when it asked to stay open so the caller can retry later.
|
|
||||||
if !effect.close_session {
|
|
||||||
self.leaf.procedure_sessions().insert(key, session);
|
|
||||||
} else {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !effect.close_session {
|
|
||||||
self.leaf.procedure_sessions().insert(key, session);
|
|
||||||
} else {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(outgoing))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_event(
|
|
||||||
&mut self,
|
|
||||||
event: LocalEvent,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
match event {
|
|
||||||
LocalEvent::Call { header, message } => self.process_local_call(header, message),
|
|
||||||
LocalEvent::Data {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key,
|
|
||||||
} => self.process_local_data(header, message, hook_key),
|
|
||||||
LocalEvent::Fault {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key,
|
|
||||||
} => self.process_local_fault(header, message, hook_key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_call(
|
|
||||||
&mut self,
|
|
||||||
header: crate::protocol::PacketHeader,
|
|
||||||
message: CallMessage,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let mut runtime = ProcedureRuntimeOutcome::default();
|
|
||||||
if message.procedure_id != P::procedure_id() {
|
|
||||||
// Once this runtime receives a call, a wrong procedure id is a protocol mismatch.
|
|
||||||
// Fault the caller rather than surfacing a leaf-local error it cannot recover from.
|
|
||||||
runtime
|
|
||||||
.frames
|
|
||||||
.extend(self.emit_internal_fault_if_possible(message.response_hook.as_ref())?);
|
|
||||||
return Ok(runtime);
|
|
||||||
}
|
|
||||||
let Some(hook) = message.response_hook.as_ref() else {
|
|
||||||
return Ok(runtime);
|
|
||||||
};
|
|
||||||
let hook_key = HookKey::new(hook.return_path.clone(), hook.hook_id);
|
|
||||||
|
|
||||||
let session = match self.open_session(header, message) {
|
|
||||||
Ok(session) => session,
|
|
||||||
Err(error) => {
|
|
||||||
// Session open failures still fault the caller when a response hook exists, but do
|
|
||||||
// not leak leaf-local details over the wire.
|
|
||||||
runtime
|
|
||||||
.frames
|
|
||||||
.extend(self.emit_internal_fault(Some(hook_key.clone()))?);
|
|
||||||
let _ = error;
|
|
||||||
return Ok(runtime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.leaf.procedure_sessions().insert(hook_key, session);
|
|
||||||
Ok(runtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_data(
|
|
||||||
&mut self,
|
|
||||||
header: crate::protocol::PacketHeader,
|
|
||||||
message: crate::protocol::DataMessage,
|
|
||||||
hook_key: HookKey,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let Some(mut session) = self.leaf.procedure_sessions().remove(&hook_key) else {
|
|
||||||
return Ok(ProcedureRuntimeOutcome::default());
|
|
||||||
};
|
|
||||||
let effect = match P::on_data(
|
|
||||||
&mut self.leaf,
|
|
||||||
&mut session,
|
|
||||||
IncomingData {
|
|
||||||
header,
|
|
||||||
message,
|
|
||||||
hook_key: hook_key.clone(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(effect) => self.ensure_terminal_packet(&hook_key, effect),
|
|
||||||
Err(error) => {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
let frames = self.emit_internal_fault(Some(hook_key.clone()))?;
|
|
||||||
let _ = error;
|
|
||||||
return Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames,
|
|
||||||
dropped: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let outgoing = match self.emit_outgoing(effect.outgoing) {
|
|
||||||
Ok(outgoing) => outgoing.frames,
|
|
||||||
Err(error) => {
|
|
||||||
if !effect.close_session {
|
|
||||||
self.leaf.procedure_sessions().insert(hook_key, session);
|
|
||||||
} else {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !effect.close_session {
|
|
||||||
self.leaf.procedure_sessions().insert(hook_key, session);
|
|
||||||
} else {
|
|
||||||
let _ = P::close(&mut self.leaf, session);
|
|
||||||
}
|
|
||||||
Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames: outgoing,
|
|
||||||
dropped: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_local_fault(
|
|
||||||
&mut self,
|
|
||||||
header: crate::protocol::PacketHeader,
|
|
||||||
message: crate::protocol::FaultMessage,
|
|
||||||
hook_key: HookKey,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let Some(mut session) = self.leaf.procedure_sessions().remove(&hook_key) else {
|
|
||||||
return Ok(ProcedureRuntimeOutcome::default());
|
|
||||||
};
|
|
||||||
let on_fault_result = P::on_fault(
|
|
||||||
&mut self.leaf,
|
|
||||||
&mut session,
|
|
||||||
IncomingFault {
|
|
||||||
header,
|
|
||||||
fault: message,
|
|
||||||
hook_key: hook_key.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Always attempt both the fault observer and the final close hook so resource cleanup can
|
|
||||||
// still run even when the leaf reports an error while handling the fault.
|
|
||||||
let close_result = P::close(&mut self.leaf, session);
|
|
||||||
if let Err(error) = on_fault_result {
|
|
||||||
let _ = close_result;
|
|
||||||
let frames = self.emit_internal_fault(Some(hook_key.clone()))?;
|
|
||||||
let _ = error;
|
|
||||||
return Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames,
|
|
||||||
dropped: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Err(error) = close_result {
|
|
||||||
let frames = self.emit_internal_fault(Some(hook_key))?;
|
|
||||||
let _ = error;
|
|
||||||
return Ok(ProcedureRuntimeOutcome {
|
|
||||||
frames,
|
|
||||||
dropped: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(ProcedureRuntimeOutcome::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_session(
|
|
||||||
&mut self,
|
|
||||||
header: crate::protocol::PacketHeader,
|
|
||||||
message: CallMessage,
|
|
||||||
) -> Result<P, DispatchError<P::Error>> {
|
|
||||||
let CallMessage {
|
|
||||||
procedure_id,
|
|
||||||
data,
|
|
||||||
response_hook,
|
|
||||||
} = message;
|
|
||||||
let input =
|
|
||||||
decode_call_input::<P::Input>(data.as_slice()).map_err(DispatchError::Decode)?;
|
|
||||||
P::open(
|
|
||||||
&mut self.leaf,
|
|
||||||
super::Call {
|
|
||||||
input,
|
|
||||||
caller_path: header.src_path,
|
|
||||||
procedure_id,
|
|
||||||
dst_leaf: header.dst_leaf,
|
|
||||||
response_hook: response_hook
|
|
||||||
.map(|hook| HookKey::new(hook.return_path, hook.hook_id)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(DispatchError::Handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_outgoing(
|
|
||||||
&mut self,
|
|
||||||
outgoing: Vec<OutgoingData>,
|
|
||||||
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let mut runtime = ProcedureRuntimeOutcome::default();
|
|
||||||
for packet in outgoing {
|
|
||||||
let endpoint_outcome = self.endpoint.send_data(
|
|
||||||
packet.dst_path,
|
|
||||||
packet.hook_id,
|
|
||||||
packet.procedure_id,
|
|
||||||
packet.data,
|
|
||||||
packet.end_hook,
|
|
||||||
)?;
|
|
||||||
runtime
|
|
||||||
.frames
|
|
||||||
.extend(self.process_endpoint_outcome(endpoint_outcome)?.frames);
|
|
||||||
}
|
|
||||||
Ok(runtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emits an upstream internal fault for the current procedure if the caller
|
|
||||||
/// declared a response hook.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// # use unshell::protocol::tree::ProcedureRuntime;
|
|
||||||
/// # struct Leaf;
|
|
||||||
/// # struct Proc;
|
|
||||||
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
|
|
||||||
/// ```
|
|
||||||
pub fn emit_internal_fault_if_possible(
|
|
||||||
&mut self,
|
|
||||||
hook: Option<&HookTarget>,
|
|
||||||
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let Some(HookTarget {
|
|
||||||
return_path,
|
|
||||||
hook_id,
|
|
||||||
}) = hook
|
|
||||||
else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let outcome = self.endpoint.emit_fault_if_possible(
|
|
||||||
Some(HookKey::new(return_path.clone(), *hook_id)),
|
|
||||||
ProtocolFault::INTERNAL_ERROR,
|
|
||||||
)?;
|
|
||||||
Ok(self.process_endpoint_outcome(outcome)?.frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_internal_fault(
|
|
||||||
&mut self,
|
|
||||||
hook_key: Option<HookKey>,
|
|
||||||
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>> {
|
|
||||||
let outcome = self
|
|
||||||
.endpoint
|
|
||||||
.emit_fault_if_possible(hook_key, ProtocolFault::INTERNAL_ERROR)?;
|
|
||||||
Ok(self.process_endpoint_outcome(outcome)?.frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures a closing session leaves the protocol hook in a fully terminated state.
|
|
||||||
///
|
|
||||||
/// If leaf code requests `close_session` without emitting an explicit terminal packet, the
|
|
||||||
/// runtime synthesizes an empty final `Data` frame so the hook closes cleanly on the wire.
|
|
||||||
fn ensure_terminal_packet(
|
|
||||||
&self,
|
|
||||||
hook_key: &HookKey,
|
|
||||||
mut effect: ProcedureEffect,
|
|
||||||
) -> ProcedureEffect {
|
|
||||||
// Once a session emits `end_hook`, later packets would violate the protocol,
|
|
||||||
// so the runtime keeps only the prefix through that terminal packet.
|
|
||||||
if let Some(index) = effect.outgoing.iter().position(|packet| packet.end_hook) {
|
|
||||||
// The protocol allows only one terminal packet per direction, so ignore anything a
|
|
||||||
// procedure tried to emit after the first close marker.
|
|
||||||
effect.outgoing.truncate(index + 1);
|
|
||||||
}
|
|
||||||
let local_end_already_sent = self
|
|
||||||
.endpoint
|
|
||||||
.hooks
|
|
||||||
.active(hook_key)
|
|
||||||
.is_none_or(|active| active.local_ended);
|
|
||||||
if effect.close_session
|
|
||||||
&& !effect.outgoing.iter().any(|packet| packet.end_hook)
|
|
||||||
&& !local_end_already_sent
|
|
||||||
{
|
|
||||||
// Closing a session without an explicit terminal packet would leave the
|
|
||||||
// protocol hook half-open, so emit an empty terminal frame on behalf of
|
|
||||||
// the procedure unless the local side already ended earlier.
|
|
||||||
effect.outgoing.push(OutgoingData {
|
|
||||||
dst_path: hook_key.return_path.clone(),
|
|
||||||
hook_id: hook_key.hook_id,
|
|
||||||
procedure_id: P::procedure_id(),
|
|
||||||
data: Vec::new(),
|
|
||||||
end_hook: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
effect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
//! Path routing helpers and explicit enum tree declarations.
|
|
||||||
//!
|
|
||||||
//! Routing follows a longest-prefix rule over endpoint paths. Each endpoint boundary can compile
|
|
||||||
//! its children into a small trie so repeated route decisions do not need to scan every child.
|
|
||||||
|
|
||||||
use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
|
|
||||||
|
|
||||||
/// Explicit tree declaration used for configuration and tests.
|
|
||||||
///
|
|
||||||
/// This models one protocol tree declaratively so callers can derive endpoint paths, leaf
|
|
||||||
/// inventory, or test fixtures without first constructing live endpoints.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{LeafNode, TreeNode};
|
|
||||||
/// let tree = TreeNode::Root {
|
|
||||||
/// children: vec![TreeNode::Endpoint {
|
|
||||||
/// segment: "worker".into(),
|
|
||||||
/// leaves: vec![LeafNode {
|
|
||||||
/// name: "service".into(),
|
|
||||||
/// procedures: vec!["example.service.v1.invoke".into()],
|
|
||||||
/// }],
|
|
||||||
/// children: Vec::new(),
|
|
||||||
/// }],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(tree.paths().len(), 2);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TreeNode {
|
|
||||||
/// The protocol root. Its path is always empty.
|
|
||||||
Root {
|
|
||||||
/// Direct child endpoints hosted below the root.
|
|
||||||
children: Vec<Self>,
|
|
||||||
},
|
|
||||||
/// An addressable endpoint segment in the tree.
|
|
||||||
Endpoint {
|
|
||||||
/// Path segment contributed by this endpoint.
|
|
||||||
segment: String,
|
|
||||||
/// Leaves hosted directly at this endpoint.
|
|
||||||
leaves: Vec<LeafNode>,
|
|
||||||
/// Direct child endpoints hosted below this endpoint.
|
|
||||||
children: Vec<Self>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leaf declaration used inside the explicit tree enum.
|
|
||||||
///
|
|
||||||
/// This exists so declarative trees can describe the leaves hosted at one endpoint without
|
|
||||||
/// constructing the full runtime state machine.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::LeafNode;
|
|
||||||
/// let leaf = LeafNode {
|
|
||||||
/// name: "service".into(),
|
|
||||||
/// procedures: vec!["example.service.v1.invoke".into()],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(leaf.name, "service");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LeafNode {
|
|
||||||
/// Leaf name local to an endpoint path.
|
|
||||||
pub name: String,
|
|
||||||
/// Procedures served by this leaf.
|
|
||||||
pub procedures: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TreeNode {
|
|
||||||
/// Flattens the explicit tree into the set of endpoint paths it declares.
|
|
||||||
///
|
|
||||||
/// The returned list always includes the protocol root as `[]`.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::TreeNode;
|
|
||||||
/// let tree = TreeNode::Root {
|
|
||||||
/// children: vec![TreeNode::Endpoint {
|
|
||||||
/// segment: "worker".into(),
|
|
||||||
/// leaves: Vec::new(),
|
|
||||||
/// children: Vec::new(),
|
|
||||||
/// }],
|
|
||||||
/// };
|
|
||||||
/// assert_eq!(tree.paths(), vec![Vec::<String>::new(), vec!["worker".into()]]);
|
|
||||||
/// ```
|
|
||||||
pub fn paths(&self) -> Vec<Vec<String>> {
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
self.collect_paths(&[], &mut paths);
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_paths(&self, prefix: &[String], paths: &mut Vec<Vec<String>>) {
|
|
||||||
match self {
|
|
||||||
Self::Root { children } => {
|
|
||||||
paths.push(Vec::new());
|
|
||||||
for child in children {
|
|
||||||
// Root always restarts collection from the empty path.
|
|
||||||
child.collect_paths(&[], paths);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self::Endpoint {
|
|
||||||
segment, children, ..
|
|
||||||
} => {
|
|
||||||
let mut next = prefix.to_vec();
|
|
||||||
next.push(segment.clone());
|
|
||||||
paths.push(next.clone());
|
|
||||||
for child in children {
|
|
||||||
child.collect_paths(&next, paths);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Longest-prefix route decision.
|
|
||||||
///
|
|
||||||
/// Each decision is evaluated from one endpoint's perspective after comparing its own path and
|
|
||||||
/// compiled child subtree against the destination path.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::RouteDecision;
|
|
||||||
/// let route = RouteDecision::Child(0);
|
|
||||||
/// assert!(matches!(route, RouteDecision::Child(0)));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum RouteDecision {
|
|
||||||
/// Forward to the child at the given local child index.
|
|
||||||
Child(usize),
|
|
||||||
/// Deliver locally at this endpoint.
|
|
||||||
Local,
|
|
||||||
/// Forward upward because the destination is outside the local subtree.
|
|
||||||
Parent,
|
|
||||||
/// Drop because no local, child, or parent route applies.
|
|
||||||
Drop,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One compiled routing table for one endpoint boundary.
|
|
||||||
///
|
|
||||||
/// This exists so repeated route lookups can reuse one longest-prefix trie instead of scanning
|
|
||||||
/// every child path on every packet.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{CompiledRoutes, RouteDecision};
|
|
||||||
/// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true);
|
|
||||||
/// assert_eq!(routes.route(&["root".into(), "worker".into(), "job".into()]), RouteDecision::Child(0));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct CompiledRoutes {
|
|
||||||
local_path: Vec<String>,
|
|
||||||
has_parent: bool,
|
|
||||||
nodes: Vec<RouteTrieNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct RouteTrieNode {
|
|
||||||
/// Child selected when traversal stops exactly at this trie node.
|
|
||||||
best_child: Option<usize>,
|
|
||||||
edges: BTreeMap<String, usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CompiledRoutes {
|
|
||||||
/// Compiles child endpoint paths into a trie rooted at `local_path`.
|
|
||||||
///
|
|
||||||
/// Only strict descendants of `local_path` participate in the compiled trie. Paths outside
|
|
||||||
/// the local subtree, or equal to `local_path` itself, are ignored.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::CompiledRoutes;
|
|
||||||
/// let routes = CompiledRoutes::new(
|
|
||||||
/// &["root".into()],
|
|
||||||
/// &[
|
|
||||||
/// vec!["root".into(), "worker".into()],
|
|
||||||
/// vec!["other".into()],
|
|
||||||
/// ],
|
|
||||||
/// true,
|
|
||||||
/// );
|
|
||||||
/// assert_eq!(routes.route(&["root".into(), "worker".into()]), unshell::protocol::tree::RouteDecision::Child(0));
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(local_path: &[String], child_paths: &[Vec<String>], has_parent: bool) -> Self {
|
|
||||||
let mut routes = Self {
|
|
||||||
local_path: local_path.to_vec(),
|
|
||||||
has_parent,
|
|
||||||
nodes: vec![RouteTrieNode::default()],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (index, child_path) in child_paths.iter().enumerate() {
|
|
||||||
routes.insert_child(index, child_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
routes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_child(&mut self, index: usize, child_path: &[String]) {
|
|
||||||
if !is_prefix(&self.local_path, child_path) || child_path.len() <= self.local_path.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store only strict descendants. The terminal node records which direct child owns that
|
|
||||||
// descendant boundary so later lookups can recover the longest matching child index.
|
|
||||||
let mut node_index = 0usize;
|
|
||||||
for segment in &child_path[self.local_path.len()..] {
|
|
||||||
let next_index = if let Some(next_index) = self.nodes[node_index].edges.get(segment) {
|
|
||||||
*next_index
|
|
||||||
} else {
|
|
||||||
let next_index = self.nodes.len();
|
|
||||||
self.nodes.push(RouteTrieNode::default());
|
|
||||||
self.nodes[node_index]
|
|
||||||
.edges
|
|
||||||
.insert(segment.clone(), next_index);
|
|
||||||
next_index
|
|
||||||
};
|
|
||||||
node_index = next_index;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.nodes[node_index].best_child = Some(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves `dst_path` using the compiled longest-prefix trie.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{CompiledRoutes, RouteDecision};
|
|
||||||
/// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true);
|
|
||||||
/// assert_eq!(routes.route(&["root".into(), "worker".into()]), RouteDecision::Child(0));
|
|
||||||
/// assert_eq!(routes.route(&["root".into()]), RouteDecision::Local);
|
|
||||||
/// assert_eq!(routes.route(&["elsewhere".into()]), RouteDecision::Parent);
|
|
||||||
/// ```
|
|
||||||
#[must_use]
|
|
||||||
pub fn route(&self, dst_path: &[String]) -> RouteDecision {
|
|
||||||
if !is_prefix(&self.local_path, dst_path) {
|
|
||||||
return if self.has_parent {
|
|
||||||
RouteDecision::Parent
|
|
||||||
} else {
|
|
||||||
RouteDecision::Drop
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut best_child = None;
|
|
||||||
let mut node_index = 0usize;
|
|
||||||
for segment in &dst_path[self.local_path.len()..] {
|
|
||||||
let Some(next_index) = self.nodes[node_index].edges.get(segment) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
node_index = *next_index;
|
|
||||||
if let Some(index) = self.nodes[node_index].best_child {
|
|
||||||
// Keep the deepest matching child seen so far; if traversal breaks later, the
|
|
||||||
// protocol still routes to the longest matching descendant boundary.
|
|
||||||
best_child = Some(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(index) = best_child {
|
|
||||||
return RouteDecision::Child(index);
|
|
||||||
}
|
|
||||||
if self.local_path == dst_path {
|
|
||||||
return RouteDecision::Local;
|
|
||||||
}
|
|
||||||
RouteDecision::Drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if `prefix` is a path prefix of `path`.
|
|
||||||
///
|
|
||||||
/// This exists as the shared path-comparison primitive for both declarative tree processing and
|
|
||||||
/// runtime route compilation.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::is_prefix;
|
|
||||||
/// assert!(is_prefix(&["root".into()], &["root".into(), "worker".into()]));
|
|
||||||
/// assert!(!is_prefix(&["worker".into()], &["root".into(), "worker".into()]));
|
|
||||||
/// ```
|
|
||||||
pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
|
|
||||||
prefix.len() <= path.len()
|
|
||||||
&& prefix
|
|
||||||
.iter()
|
|
||||||
.zip(path.iter())
|
|
||||||
.all(|(left, right)| left == right)
|
|
||||||
}
|
|
||||||
/// Trait for resolving a destination path to a routing decision.
|
|
||||||
///
|
|
||||||
/// The default policy is longest-prefix routing: exact matches stay local, the deepest matching
|
|
||||||
/// descendant wins for child forwarding, destinations outside the local subtree go to the parent
|
|
||||||
/// when one exists, and everything else drops.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider};
|
|
||||||
/// let provider = DefaultRouteProvider;
|
|
||||||
/// let route = provider.route_destination(
|
|
||||||
/// &["root".into()],
|
|
||||||
/// [vec!["root".into(), "worker".into()]],
|
|
||||||
/// true,
|
|
||||||
/// &["root".into(), "worker".into()],
|
|
||||||
/// );
|
|
||||||
/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0)));
|
|
||||||
/// ```
|
|
||||||
pub trait RouteProvider {
|
|
||||||
/// Returns the route decision for `dst_path` from the perspective of `local_path`.
|
|
||||||
fn route_destination<I>(
|
|
||||||
&self,
|
|
||||||
local_path: &[String],
|
|
||||||
child_paths: I,
|
|
||||||
has_parent: bool,
|
|
||||||
dst_path: &[String],
|
|
||||||
) -> RouteDecision
|
|
||||||
where
|
|
||||||
I: IntoIterator,
|
|
||||||
I::Item: AsRef<[String]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default routing implementation using the protocol's longest-prefix rule.
|
|
||||||
///
|
|
||||||
/// This exists as the stateless policy object behind the free [`route_destination`] helper and
|
|
||||||
/// as a customization seam for tests or alternate routing strategies.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider};
|
|
||||||
/// let provider = DefaultRouteProvider;
|
|
||||||
/// let route = provider.route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]);
|
|
||||||
/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0)));
|
|
||||||
/// ```
|
|
||||||
pub struct DefaultRouteProvider;
|
|
||||||
|
|
||||||
impl RouteProvider for DefaultRouteProvider {
|
|
||||||
fn route_destination<I>(
|
|
||||||
&self,
|
|
||||||
local_path: &[String],
|
|
||||||
child_paths: I,
|
|
||||||
has_parent: bool,
|
|
||||||
dst_path: &[String],
|
|
||||||
) -> RouteDecision
|
|
||||||
where
|
|
||||||
I: IntoIterator,
|
|
||||||
I::Item: AsRef<[String]>,
|
|
||||||
{
|
|
||||||
let child_paths = child_paths
|
|
||||||
.into_iter()
|
|
||||||
.map(|child| child.as_ref().to_vec())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
CompiledRoutes::new(local_path, &child_paths, has_parent).route(dst_path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves `dst_path` with the default longest-prefix route provider.
|
|
||||||
///
|
|
||||||
/// Exact matches return [`RouteDecision::Local`]. Destinations outside the local subtree return
|
|
||||||
/// [`RouteDecision::Parent`] when `has_parent` is `true`, otherwise [`RouteDecision::Drop`].
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// use unshell::protocol::tree::{RouteDecision, route_destination};
|
|
||||||
/// let route = route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]);
|
|
||||||
/// assert_eq!(route, RouteDecision::Child(0));
|
|
||||||
/// ```
|
|
||||||
pub fn route_destination<I>(
|
|
||||||
local_path: &[String],
|
|
||||||
child_paths: I,
|
|
||||||
has_parent: bool,
|
|
||||||
dst_path: &[String],
|
|
||||||
) -> RouteDecision
|
|
||||||
where
|
|
||||||
I: IntoIterator,
|
|
||||||
I::Item: AsRef<[String]>,
|
|
||||||
{
|
|
||||||
DefaultRouteProvider.route_destination(local_path, child_paths, has_parent, dst_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use alloc::{string::String, vec};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn longest_prefix_wins() {
|
|
||||||
let provider = DefaultRouteProvider;
|
|
||||||
let children = vec![
|
|
||||||
vec![String::from("a")],
|
|
||||||
vec![String::from("a"), String::from("b")],
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
provider.route_destination(
|
|
||||||
&Vec::<String>::new(),
|
|
||||||
children,
|
|
||||||
false,
|
|
||||||
&[String::from("a"), String::from("b"), String::from("c")]
|
|
||||||
),
|
|
||||||
RouteDecision::Child(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compiled_routes_choose_longest_prefix_without_child_scan() {
|
|
||||||
let table = CompiledRoutes::new(
|
|
||||||
&[String::from("a")],
|
|
||||||
&[
|
|
||||||
vec![String::from("a"), String::from("b")],
|
|
||||||
vec![String::from("a"), String::from("x")],
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
table.route(&[String::from("a"), String::from("b"), String::from("c")]),
|
|
||||||
RouteDecision::Child(0)
|
|
||||||
);
|
|
||||||
assert_eq!(table.route(&[String::from("z")]), RouteDecision::Parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tree_enum_flattens_paths() {
|
|
||||||
let tree = TreeNode::Root {
|
|
||||||
children: vec![TreeNode::Endpoint {
|
|
||||||
segment: String::from("a"),
|
|
||||||
leaves: Vec::new(),
|
|
||||||
children: vec![TreeNode::Endpoint {
|
|
||||||
segment: String::from("b"),
|
|
||||||
leaves: Vec::new(),
|
|
||||||
children: Vec::new(),
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
tree.paths(),
|
|
||||||
vec![
|
|
||||||
Vec::<String>::new(),
|
|
||||||
vec![String::from("a")],
|
|
||||||
vec![String::from("a"), String::from("b")],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user