mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Split interface store into modules.
This commit is contained in:
+118
-771
@@ -1,810 +1,157 @@
|
|||||||
# Macro-Generated Leaf Interface Design
|
# Template Leaf Interface Design
|
||||||
|
|
||||||
**Status:** Draft
|
**Status:** Implemented draft
|
||||||
**Last updated:** 2026-05-28
|
**Last updated:** 2026-05-31
|
||||||
**Primary use case:** Remote PTY sessions over hook-backed UnShell packets
|
**Primary use case:** Small generated leaf wrappers without proc-macro machinery
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
This document proposes a generated leaf interface for UnShell. The goal is to make
|
Leaf generation now uses a declarative `unshell_leaf!` template instead of the old
|
||||||
stateful leaves, such as a remote PTY leaf, easy to write without forcing every leaf
|
`#[unshell_leaf]` proc macro. The goal is to make generated code obvious, closer to
|
||||||
author to hand-code packet draining, procedure dispatch, session lookup, hook
|
an HTML template than an AST transformation.
|
||||||
lifetime handling, and retry-safe final-frame cleanup.
|
|
||||||
|
|
||||||
The user writes the application logic:
|
The macro only fills slots:
|
||||||
|
|
||||||
- the leaf state struct
|
- wrapper name
|
||||||
- one or more session types for long-running hook-backed conversations
|
- user state type
|
||||||
- one or more procedure types for one-packet operations
|
- leaf id
|
||||||
- payload encoding and decoding
|
- interface metadata
|
||||||
- OS-specific behavior, such as spawning or polling a PTY
|
- named session families
|
||||||
|
- named procedure families
|
||||||
|
|
||||||
The macro generates the plumbing:
|
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.
|
||||||
|
|
||||||
- the `Leaf` implementation
|
## User Shape
|
||||||
- the generated wrapper that owns session stores and retry queues
|
|
||||||
- inbound packet filtering for the leaf's procedure ids
|
|
||||||
- per-packet procedure dispatch
|
|
||||||
- per-hook session dispatch
|
|
||||||
- retry-safe outbound flushing
|
|
||||||
- final-frame session removal only after routing succeeds
|
|
||||||
|
|
||||||
The macro should generate ordinary Rust. No runtime registry, no boxed procedure
|
|
||||||
objects, and no hidden dynamic dispatch in the hot path.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The current `Leaf` trait is deliberately small:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait Leaf {
|
pub struct FakePtyState {
|
||||||
fn get_id(&self) -> u32;
|
pub active_count: usize,
|
||||||
|
pub total_opened: u64,
|
||||||
fn update(&mut self, endpoint: &mut Endpoint);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That makes the protocol runtime flexible, but it also means every non-trivial leaf
|
|
||||||
has to solve the same set of problems by hand:
|
|
||||||
|
|
||||||
- select only the inbound packets that belong to this leaf
|
|
||||||
- distinguish one-shot procedures from long-running sessions
|
|
||||||
- group session packets by `hook_id`
|
|
||||||
- keep per-session application state
|
|
||||||
- build response packets with the right `hook_id`, path, procedure id, and `end_hook`
|
|
||||||
- retry failed outbound packets without losing stream progress
|
|
||||||
- remove session state only after final packets route successfully
|
|
||||||
- avoid consuming packets intended for other leaves
|
|
||||||
|
|
||||||
A remote PTY leaf makes this pain obvious. A PTY session is bidirectional and long
|
|
||||||
lived. The same hook carries `Open`, `Input`, `Resize`, `StdinEof`, `Output`, `Exit`,
|
|
||||||
and errors. The endpoint already owns hook authorization, but the leaf still needs a
|
|
||||||
safe session state machine.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Automate the repetitive `Leaf::update` machinery.
|
|
||||||
- Keep application logic explicit and testable.
|
|
||||||
- Keep `unshell-protocol` minimal and no_std-friendly.
|
|
||||||
- Keep OS-specific PTY code outside `unshell-protocol`.
|
|
||||||
- Preserve the new endpoint hook model: downward packets pave hooks, final packets close hooks.
|
|
||||||
- Make final-frame retries hard to get wrong.
|
|
||||||
- Keep generated runtime code static and size-conscious.
|
|
||||||
- Let sessions and procedures mutate shared leaf state without directly accessing each other.
|
|
||||||
- Make multiple sessions of the same type cheap and predictable.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Do not define a full actor framework.
|
|
||||||
- Do not add async requirements to the protocol layer.
|
|
||||||
- Do not make sessions discover or mutate other sessions directly.
|
|
||||||
- Do not introduce `Vec<Box<dyn Procedure>>` or `Vec<Box<dyn Session>>` runtime registries.
|
|
||||||
- Do not hide PTY business logic inside the macro.
|
|
||||||
- Do not add source-path fields to `Packet` just for PTY. The PTY `Open` payload can carry the reply path.
|
|
||||||
|
|
||||||
## Current Protocol Assumptions
|
|
||||||
|
|
||||||
The endpoint routing layer now owns hook lifetime:
|
|
||||||
|
|
||||||
```text
|
|
||||||
validated downward packet, end_hook=false -> open or refresh peer-bound hook
|
|
||||||
validated downward packet, end_hook=true -> close hook after successful route or delivery
|
|
||||||
validated upward packet -> require matching hook
|
|
||||||
validated upward packet, end_hook=true -> close hook after successful route or delivery
|
|
||||||
```
|
|
||||||
|
|
||||||
For PTY, this means:
|
|
||||||
|
|
||||||
- `Open` uses `end_hook = false` because it expects returned output.
|
|
||||||
- `Input`, `Resize`, `StdinEof`, and `Terminate` use `end_hook = false`.
|
|
||||||
- `StdinEof` is not `end_hook`. EOF closes stdin, not the whole PTY session.
|
|
||||||
- `Abort` may use `end_hook = true` if no acknowledgement is expected.
|
|
||||||
- `Output` uses `end_hook = false`.
|
|
||||||
- `Exit` or fatal `Error` uses `end_hook = true`.
|
|
||||||
|
|
||||||
## Crate Boundary
|
|
||||||
|
|
||||||
```text
|
|
||||||
unshell-protocol
|
|
||||||
Endpoint
|
|
||||||
Packet
|
|
||||||
Leaf
|
|
||||||
hook routing rules
|
|
||||||
filtered inbound drain API
|
|
||||||
|
|
||||||
unshell-runtime or unshell-leaves
|
|
||||||
real PTY worker implementation
|
|
||||||
std-only integrations
|
|
||||||
portable-pty adapter
|
|
||||||
|
|
||||||
unshell-macros
|
|
||||||
tiny proc-macro shim
|
|
||||||
|
|
||||||
unshell-macros-core
|
|
||||||
syn, quote, proc-macro2, deluxe or darling
|
|
||||||
parser and code generator tests
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated code runs in the final binary. Macro parsing dependencies do not.
|
|
||||||
That means `syn`, `quote`, `deluxe`, and `darling` are acceptable in the macro crates,
|
|
||||||
but not in the protocol runtime.
|
|
||||||
|
|
||||||
## Required Endpoint Addition
|
|
||||||
|
|
||||||
The generated leaf must be able to drain only the packets it owns. The current
|
|
||||||
`take_inbound_clear` drains a whole local queue, which is unsafe once multiple
|
|
||||||
application leaves share an endpoint.
|
|
||||||
|
|
||||||
Add a filtered drain API:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
impl Endpoint {
|
|
||||||
/// Drains packets from `local_id` that match `predicate`, preserving all other
|
|
||||||
/// packets in their original relative order.
|
|
||||||
pub fn take_inbound_matching<P, F>(&mut self, local_id: u32, predicate: P, f: F)
|
|
||||||
where
|
|
||||||
P: FnMut(&Packet) -> bool,
|
|
||||||
F: FnMut(&Packet);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For generated leaves, matching usually means:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
packet.procedure_id == PROC_PTY
|
|
||||||
|| packet.procedure_id == PROC_PING
|
|
||||||
|| packet.procedure_id == PROC_CAPABILITIES
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the one endpoint API the macro needs before it can be safe in mixed-leaf endpoints.
|
|
||||||
|
|
||||||
## User-Facing Macro
|
|
||||||
|
|
||||||
Use an attribute macro that wraps a user-owned state struct and generates a leaf type.
|
|
||||||
A derive macro alone cannot add storage fields to the original struct, so the macro
|
|
||||||
must generate a companion wrapper.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[unshell_leaf(
|
|
||||||
leaf = RemotePtyLeaf,
|
|
||||||
id = LEAF_REMOTE_PTY,
|
|
||||||
sessions(PtySession),
|
|
||||||
procedures(PingProcedure, CapabilitiesProcedure)
|
|
||||||
)]
|
|
||||||
pub struct RemotePtyState {
|
|
||||||
max_sessions: usize,
|
|
||||||
default_rows: u16,
|
|
||||||
default_cols: u16,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The macro emits roughly:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct RemotePtyLeaf {
|
|
||||||
state: RemotePtyState,
|
|
||||||
pty_sessions: SessionStore<PtySession>,
|
|
||||||
retry: PacketQueue,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemotePtyLeaf {
|
unshell_leaf! {
|
||||||
pub fn new(state: RemotePtyState) -> Self { ... }
|
pub leaf FakePtyLeaf for FakePtyState {
|
||||||
}
|
id: LEAF_FAKE_PTY,
|
||||||
|
meta: unshell::protocol::LeafMeta {
|
||||||
impl Leaf for RemotePtyLeaf {
|
name: "Fake PTY Leaf",
|
||||||
fn get_id(&self) -> u32 {
|
identifier: "dev.unshell.v1.pty",
|
||||||
LEAF_REMOTE_PTY
|
version: "v0",
|
||||||
|
authors: unshell::alloc::vec!["ASTATIN3"],
|
||||||
|
},
|
||||||
|
sessions {
|
||||||
|
pty: PtySession,
|
||||||
}
|
}
|
||||||
|
procedures {}
|
||||||
fn update(&mut self, endpoint: &mut Endpoint) {
|
|
||||||
... generated dispatch ...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The leaf wrapper is the object stored in `Endpoint::leaves`. The state struct stays
|
The field name before each session type is explicit. The macro does not invent a
|
||||||
small and owned by the user.
|
field name from the Rust type.
|
||||||
|
|
||||||
## Core Model
|
## 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
|
```text
|
||||||
Endpoint inbound queue
|
InterfaceStore
|
||||||
|
|
events: Vec<InterfaceEvent>
|
||||||
v
|
sessions: BTreeMap<SessionKey, SessionView>
|
||||||
+-------------------------------+
|
procedures: BTreeMap<ProcedureKey, ProcedureView>
|
||||||
| generated RemotePtyLeaf |
|
|
||||||
| |
|
|
||||||
| state: RemotePtyState |
|
|
||||||
| sessions: SessionStore |
|
|
||||||
| retry: PacketQueue |
|
|
||||||
+-------------------------------+
|
|
||||||
|
|
|
||||||
+--> Procedure packet -> Procedure::handle(...)
|
|
||||||
|
|
|
||||||
+--> Session packet -> by hook_id
|
|
||||||
create or update session
|
|
||||||
session queues outbound frames
|
|
||||||
generated flush handles retry
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Procedures and sessions can both mutate `RemotePtyState`. They cannot directly
|
Generated leaves receive an optional mutable store during `update_interface`. The
|
||||||
borrow or mutate each other. Communication between them must happen through leaf
|
helpers create and update the appropriate session/procedure views when packets are
|
||||||
state or through packets.
|
dispatched, sessions update, and outbound routes succeed or fail.
|
||||||
|
|
||||||
## Sessions
|
Time remains caller-supplied:
|
||||||
|
|
||||||
A session is a long-running hook-backed conversation. PTY is the main example.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait Session<L> {
|
interface.set_now_ns(Some(now_ns));
|
||||||
/// All packets for this session type use this outer procedure id.
|
leaf.update_interface(endpoint, &mut interface);
|
||||||
const PROCEDURE_ID: u32;
|
|
||||||
|
|
||||||
/// State owned for one active hook/session.
|
|
||||||
type State;
|
|
||||||
|
|
||||||
/// Attempts to create a new session from an incoming packet.
|
|
||||||
fn init(
|
|
||||||
leaf: &mut L,
|
|
||||||
packet: Packet,
|
|
||||||
ctx: &mut SessionInit,
|
|
||||||
) -> SessionInitResult<Self::State>;
|
|
||||||
|
|
||||||
/// Advances one session. The generated leaf passes all queued packets for this
|
|
||||||
/// hook and one context that can enqueue outbound frames.
|
|
||||||
fn update(
|
|
||||||
leaf: &mut L,
|
|
||||||
session: &mut Self::State,
|
|
||||||
incoming: &mut PacketQueue,
|
|
||||||
ctx: &mut SessionCtx,
|
|
||||||
) -> SessionStatus;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`L` is the user state type, for example `RemotePtyState`.
|
No clock is embedded in the no_std protocol layer.
|
||||||
|
|
||||||
### Session Init Context
|
## Ratatui Rendering
|
||||||
|
|
||||||
|
Ratatui rendering is a plain feature-gated pass:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct SessionInit {
|
leaf.render_ratatui(frame, area, &mut interface);
|
||||||
hook_id: HookID,
|
```
|
||||||
packet_path: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum SessionInitResult<S> {
|
Session rendering is an associated function because session families are type-level
|
||||||
Created(S),
|
contracts, not stored objects:
|
||||||
Rejected,
|
|
||||||
RejectedWith(Packet),
|
```rust
|
||||||
|
fn render_ratatui(
|
||||||
|
leaf: &LeafState,
|
||||||
|
session: &Self::State,
|
||||||
|
view: &mut SessionView,
|
||||||
|
frame: &mut ratatui::Frame<'_>,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`RejectedWith(Packet)` is intended for cases where the initializer can build a
|
Procedure rendering is also associated and renders from leaf state plus the caller
|
||||||
protocol-level failure response, such as "too many PTY sessions". The generated leaf
|
owned procedure view.
|
||||||
still owns routing and retry for that packet.
|
|
||||||
|
|
||||||
The PTY `Open` payload should include the caller reply path because `Packet` does
|
## Why This Replaced The Proc Macro
|
||||||
not currently carry source path.
|
|
||||||
|
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
|
```text
|
||||||
Open payload:
|
macro template -> named fields and loops
|
||||||
opcode
|
runtime helpers -> behavior
|
||||||
reply_path_len
|
caller InterfaceStore -> UI/log state
|
||||||
reply_path segments
|
|
||||||
rows
|
|
||||||
cols
|
|
||||||
command/env/options
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The session stores that reply path and uses it for upward output packets.
|
That is the whole game.
|
||||||
|
|
||||||
### Session Update Context
|
|
||||||
|
|
||||||
Sessions should use a context wrapper rather than directly constructing packets.
|
|
||||||
The context can still carry restricted endpoint access when absolutely necessary,
|
|
||||||
but the normal output path should be helper methods.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SessionCtx<'a> {
|
|
||||||
endpoint: &'a mut Endpoint,
|
|
||||||
hook_id: HookID,
|
|
||||||
reply_path: &'a [u32],
|
|
||||||
procedure_id: u32,
|
|
||||||
outbox: &'a mut PacketQueue,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Helpers:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
impl<'a> SessionCtx<'a> {
|
|
||||||
pub fn send(&mut self, opcode: u8, data: &[u8]);
|
|
||||||
|
|
||||||
pub fn send_final(&mut self, opcode: u8, data: &[u8]);
|
|
||||||
|
|
||||||
pub fn error(&mut self, code: u8, data: &[u8]);
|
|
||||||
|
|
||||||
pub fn error_final(&mut self, code: u8, data: &[u8]);
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
These helpers build packets like:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Packet {
|
|
||||||
hook_id: self.hook_id,
|
|
||||||
end_hook,
|
|
||||||
path: self.reply_path.to_vec(),
|
|
||||||
procedure_id: self.procedure_id,
|
|
||||||
data: encode_frame(opcode, payload),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper only queues packets. It does not route them immediately. The generated
|
|
||||||
leaf owns flushing and retry.
|
|
||||||
|
|
||||||
### Session Status
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum SessionStatus {
|
|
||||||
Running,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Closed` means the session has no more application work. The generated leaf still
|
|
||||||
must keep the session until any final packet has routed successfully.
|
|
||||||
|
|
||||||
## Procedures
|
|
||||||
|
|
||||||
A procedure is a one-packet operation. It is appropriate for introspection, ping,
|
|
||||||
capabilities, and simple state queries.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait Procedure<L> {
|
|
||||||
const PROCEDURE_ID: u32;
|
|
||||||
|
|
||||||
fn handle(
|
|
||||||
leaf: &mut L,
|
|
||||||
endpoint: &mut Endpoint,
|
|
||||||
packet: Packet,
|
|
||||||
out: &mut ProcedureOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Procedure helpers mirror session helpers but operate on one incoming packet:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct ProcedureOut {
|
|
||||||
hook_id: HookID,
|
|
||||||
reply_path: Vec<u32>,
|
|
||||||
procedure_id: u32,
|
|
||||||
outbox: PacketQueue,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureOut {
|
|
||||||
pub fn send(&mut self, data: &[u8]);
|
|
||||||
|
|
||||||
pub fn send_final(&mut self, data: &[u8]);
|
|
||||||
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Procedures do not directly access sessions. If a procedure needs information about
|
|
||||||
sessions, that information should be mirrored into leaf state by the session code.
|
|
||||||
|
|
||||||
## PTY Binary Protocol
|
|
||||||
|
|
||||||
One PTY session uses one hook and one outer procedure id. The inner PTY protocol is
|
|
||||||
a tiny binary frame in `Packet::data`.
|
|
||||||
|
|
||||||
If API names use `end_state` at the session layer, it maps directly to
|
|
||||||
`Packet::end_hook` at the protocol layer.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Packet {
|
|
||||||
hook_id: session hook,
|
|
||||||
procedure_id: PROC_PTY,
|
|
||||||
data: [opcode, payload...],
|
|
||||||
end_hook: session lifetime marker,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Suggested opcodes:
|
|
||||||
|
|
||||||
| Opcode | Direction | Meaning | end_hook |
|
|
||||||
|---:|---|---|---|
|
|
||||||
| 0 | Downward | Open PTY | false |
|
|
||||||
| 1 | Upward | Opened | false |
|
|
||||||
| 2 | Downward | Input bytes | false |
|
|
||||||
| 3 | Downward | Resize | false |
|
|
||||||
| 4 | Downward | Stdin EOF | false |
|
|
||||||
| 5 | Downward | Terminate process | false |
|
|
||||||
| 6 | Downward | Abort session without acknowledgement | true |
|
|
||||||
| 7 | Upward | Output bytes | false |
|
|
||||||
| 8 | Upward | Exit status | true |
|
|
||||||
| 9 | Upward | Fatal error | true |
|
|
||||||
|
|
||||||
`StdinEof` must not set `end_hook = true`. The remote process may still emit output
|
|
||||||
after stdin closes.
|
|
||||||
|
|
||||||
## Generated Update Loop
|
|
||||||
|
|
||||||
The generated `Leaf::update` should follow this shape:
|
|
||||||
|
|
||||||
```text
|
|
||||||
update(endpoint)
|
|
||||||
1. flush retry queue first
|
|
||||||
2. drain matching inbound packets only
|
|
||||||
3. dispatch procedure packets
|
|
||||||
4. group session packets by hook_id
|
|
||||||
5. create sessions for open packets
|
|
||||||
6. update active sessions
|
|
||||||
7. flush outbound frames
|
|
||||||
8. remove sessions whose final packet routed successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
More concrete flow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
+----------------------------+
|
|
||||||
| generated update |
|
|
||||||
+----------------------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
flush retry packets
|
|
||||||
|
|
|
||||||
v
|
|
||||||
take_inbound_matching(local_id, owns_packet)
|
|
||||||
|
|
|
||||||
+--> procedure id match -> Procedure::handle
|
|
||||||
|
|
|
||||||
+--> session id match -> session inbox by hook_id
|
|
||||||
|
|
|
||||||
v
|
|
||||||
for each session inbox
|
|
||||||
|
|
|
||||||
+--> existing hook -> Session::update
|
|
||||||
|
|
|
||||||
+--> no hook -> Session::init, then Session::update
|
|
||||||
|
|
|
||||||
v
|
|
||||||
flush session/procedure outbox
|
|
||||||
|
|
|
||||||
+--> route success -> advance/remove when safe
|
|
||||||
|
|
|
||||||
+--> route failure -> keep retry packet and keep state
|
|
||||||
```
|
|
||||||
|
|
||||||
The retry rule is the most important generated invariant:
|
|
||||||
|
|
||||||
```text
|
|
||||||
If endpoint.add_outbound(packet) fails, the generated leaf must not:
|
|
||||||
- drop the packet
|
|
||||||
- advance a final frame
|
|
||||||
- remove the session
|
|
||||||
- consume state that cannot be reconstructed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generated Dispatch
|
|
||||||
|
|
||||||
The macro should emit static matches, not runtime registries.
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
match packet.procedure_id {
|
|
||||||
PtySession::PROCEDURE_ID => self.dispatch_pty_session(packet),
|
|
||||||
PingProcedure::PROCEDURE_ID => PingProcedure::handle(...),
|
|
||||||
CapabilitiesProcedure::PROCEDURE_ID => CapabilitiesProcedure::handle(...),
|
|
||||||
_ => self.handle_unknown(packet),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Vec<Box<dyn Procedure>>
|
|
||||||
Vec<Box<dyn Session>>
|
|
||||||
```
|
|
||||||
|
|
||||||
Static dispatch keeps the generated code visible to LLVM and avoids registry setup
|
|
||||||
costs in constrained binaries.
|
|
||||||
|
|
||||||
## Session Store
|
|
||||||
|
|
||||||
The first implementation can use a small `Vec` or `VecDeque` store.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SessionEntry<S> {
|
|
||||||
hook_id: HookID,
|
|
||||||
state: S,
|
|
||||||
inbox: PacketQueue,
|
|
||||||
outbox: PacketQueue,
|
|
||||||
retry: Option<Packet>,
|
|
||||||
closing: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For minimal binaries, a later implementation can replace this with a fixed-capacity
|
|
||||||
table under a feature flag.
|
|
||||||
|
|
||||||
Required operations:
|
|
||||||
|
|
||||||
- find by `hook_id`
|
|
||||||
- insert if capacity allows
|
|
||||||
- push incoming packet to inbox
|
|
||||||
- enqueue outbound packet
|
|
||||||
- retain session while retry packet exists
|
|
||||||
- remove only after session is closed and outbox/retry are empty
|
|
||||||
|
|
||||||
## Unknown Packets
|
|
||||||
|
|
||||||
The generated leaf should have configurable unknown-packet behavior.
|
|
||||||
|
|
||||||
Default behavior:
|
|
||||||
|
|
||||||
- unknown procedure id remains in the endpoint queue because `take_inbound_matching` does not drain it
|
|
||||||
- unknown session opcode for a known session produces a fatal error frame and closes the session
|
|
||||||
- packet for unknown hook under a session procedure produces a fatal error frame if the payload is not a valid `Open`
|
|
||||||
|
|
||||||
For PTY:
|
|
||||||
|
|
||||||
```text
|
|
||||||
unknown hook + Open opcode -> create session
|
|
||||||
unknown hook + non-Open opcode -> Error end_hook=true
|
|
||||||
known hook + known opcode -> session update
|
|
||||||
known hook + unknown opcode -> Error end_hook=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access Boundaries
|
|
||||||
|
|
||||||
Sessions and procedures can mutate leaf state:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn update(leaf: &mut RemotePtyState, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
They should not directly access each other:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Procedure -> no direct SessionStore access
|
|
||||||
Session -> no direct Procedure access
|
|
||||||
```
|
|
||||||
|
|
||||||
If cross-cutting data is needed, mirror it into `RemotePtyState`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct RemotePtyState {
|
|
||||||
active_count: usize,
|
|
||||||
total_spawned: u64,
|
|
||||||
max_sessions: usize,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This keeps borrowing simple and avoids turning the generated leaf into a shared
|
|
||||||
mutable object graph.
|
|
||||||
|
|
||||||
## Endpoint Access
|
|
||||||
|
|
||||||
The user proposed passing `&mut Endpoint` to sessions and procedures. The safest
|
|
||||||
design is slightly narrower:
|
|
||||||
|
|
||||||
- procedures may receive `&mut Endpoint` because they handle one packet and return immediately
|
|
||||||
- sessions should receive `SessionCtx`, which can expose narrow endpoint helpers
|
|
||||||
- a raw endpoint escape hatch can be added later if a real leaf proves it needs one
|
|
||||||
|
|
||||||
Rationale: sessions are long-lived and retry-sensitive. If session code calls
|
|
||||||
`endpoint.add_outbound` directly, it can bypass generated retry handling and lose a
|
|
||||||
final packet. The helper path keeps the dangerous part centralized.
|
|
||||||
|
|
||||||
## Remote PTY Example
|
|
||||||
|
|
||||||
User code:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[unshell_leaf(
|
|
||||||
leaf = RemotePtyLeaf,
|
|
||||||
id = LEAF_REMOTE_PTY,
|
|
||||||
sessions(PtySession),
|
|
||||||
procedures(PtyCapabilities)
|
|
||||||
)]
|
|
||||||
pub struct RemotePtyState {
|
|
||||||
max_sessions: usize,
|
|
||||||
default_rows: u16,
|
|
||||||
default_cols: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PtySession;
|
|
||||||
|
|
||||||
pub struct PtyState {
|
|
||||||
hook_id: HookID,
|
|
||||||
reply_path: Vec<u32>,
|
|
||||||
worker: PtyWorker,
|
|
||||||
saw_stdin_eof: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session<RemotePtyState> for PtySession {
|
|
||||||
const PROCEDURE_ID: u32 = PROC_PTY;
|
|
||||||
|
|
||||||
type State = PtyState;
|
|
||||||
|
|
||||||
fn init(
|
|
||||||
leaf: &mut RemotePtyState,
|
|
||||||
packet: Packet,
|
|
||||||
ctx: &mut SessionInit,
|
|
||||||
) -> SessionInitResult<Self::State> {
|
|
||||||
let open = decode_open(&packet.data)?;
|
|
||||||
|
|
||||||
if leaf.active_count >= leaf.max_sessions {
|
|
||||||
return SessionInitResult::RejectedWith(pty_error_busy(packet.hook_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker = PtyWorker::spawn(open.command, open.rows, open.cols)?;
|
|
||||||
|
|
||||||
SessionInitResult::Created(PtyState {
|
|
||||||
hook_id: ctx.hook_id(),
|
|
||||||
reply_path: open.reply_path,
|
|
||||||
worker,
|
|
||||||
saw_stdin_eof: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(
|
|
||||||
leaf: &mut RemotePtyState,
|
|
||||||
session: &mut Self::State,
|
|
||||||
incoming: &mut PacketQueue,
|
|
||||||
ctx: &mut SessionCtx,
|
|
||||||
) -> SessionStatus {
|
|
||||||
while let Some(packet) = incoming.pop_front() {
|
|
||||||
match decode_pty_frame(&packet.data) {
|
|
||||||
PtyFrame::Input(bytes) => session.worker.write(&bytes),
|
|
||||||
PtyFrame::Resize { rows, cols } => session.worker.resize(rows, cols),
|
|
||||||
PtyFrame::StdinEof => {
|
|
||||||
session.saw_stdin_eof = true;
|
|
||||||
session.worker.close_stdin();
|
|
||||||
}
|
|
||||||
PtyFrame::Terminate => session.worker.terminate(),
|
|
||||||
PtyFrame::Abort => return SessionStatus::Closed,
|
|
||||||
_ => ctx.error_final(ERR_BAD_OPCODE, b"bad pty opcode"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some(bytes) = session.worker.poll_output() {
|
|
||||||
ctx.send(OP_OUTPUT, &bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(status) = session.worker.poll_exit() {
|
|
||||||
ctx.send_final(OP_EXIT, &encode_exit(status));
|
|
||||||
leaf.active_count -= 1;
|
|
||||||
return SessionStatus::Closed;
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionStatus::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact error handling syntax above is illustrative. The final API should avoid
|
|
||||||
`?` unless `SessionInitResult` has a clear conversion story.
|
|
||||||
|
|
||||||
## Generated Remote PTY Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Caller Remote endpoint RemotePtyLeaf
|
|
||||||
------ --------------- -------------
|
|
||||||
allocate hook
|
|
||||||
Open end=false -------------> downward route opens hook ----> init PtyState
|
|
||||||
Input false -------------> hook remains active -----------> write PTY stdin
|
|
||||||
Resize false -------------> hook remains active -----------> resize PTY
|
|
||||||
StdinEof false -------------> hook remains active -----------> close PTY stdin
|
|
||||||
|
|
||||||
Output false <------------- upward route requires hook <---- ctx.send
|
|
||||||
Output false <------------- upward route requires hook <---- ctx.send
|
|
||||||
Exit true <------------- upward route closes hook <---- ctx.send_final
|
|
||||||
|
|
||||||
caller cleanup remove session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Proc Macro Implementation
|
|
||||||
|
|
||||||
Rust still requires a proc-macro crate for real attribute macros. Keep that crate
|
|
||||||
thin and put the real logic in a normal testable crate.
|
|
||||||
|
|
||||||
```text
|
|
||||||
unshell-macros
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn unshell_leaf(attr, item) -> TokenStream
|
|
||||||
|
|
||||||
unshell-macros-core
|
|
||||||
parse UnshellLeafArgs
|
|
||||||
parse state struct
|
|
||||||
generate wrapper
|
|
||||||
generate Leaf impl
|
|
||||||
generate dispatch methods
|
|
||||||
tests over proc_macro2::TokenStream
|
|
||||||
```
|
|
||||||
|
|
||||||
Recommended parser helper:
|
|
||||||
|
|
||||||
- `deluxe` for attribute parsing if the desired syntax uses richer Rust tokens
|
|
||||||
- `darling` if the syntax stays close to normal Rust meta attributes
|
|
||||||
|
|
||||||
Current recommendation: use `deluxe` for attribute parsing and `syn`/`quote` for
|
|
||||||
code generation.
|
|
||||||
|
|
||||||
## Generated Code Requirements
|
|
||||||
|
|
||||||
- Must compile without requiring std in `unshell-protocol`.
|
|
||||||
- Must not allocate handler trait objects.
|
|
||||||
- Must preserve generic parameters and where clauses on the state struct.
|
|
||||||
- Must emit useful compile errors for duplicate procedure ids.
|
|
||||||
- Must emit useful compile errors for duplicate session procedure ids.
|
|
||||||
- Must reject session and procedure id collisions unless explicitly allowed.
|
|
||||||
- Must preserve user attributes that are not consumed by the macro.
|
|
||||||
- Must not require users to manually implement `Leaf` for the generated wrapper.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
Phase 1 tests should use a fake PTY session, not `portable-pty`.
|
|
||||||
|
|
||||||
Protocol tests:
|
|
||||||
|
|
||||||
- `open_pty_paves_hook_and_creates_session`
|
|
||||||
- `input_and_output_share_one_hook`
|
|
||||||
- `stdin_eof_keeps_hook_until_exit`
|
|
||||||
- `exit_end_hook_cleans_route_and_session`
|
|
||||||
- `failed_final_exit_route_retries_without_losing_session`
|
|
||||||
- `abort_downward_end_hook_closes_without_ack`
|
|
||||||
- `unknown_session_input_returns_error_end_hook`
|
|
||||||
- `two_pty_sessions_interleave_without_crossing_hooks`
|
|
||||||
- `pty_leaf_does_not_consume_other_leaf_packets`
|
|
||||||
|
|
||||||
Macro tests:
|
|
||||||
|
|
||||||
- generated leaf implements `Leaf`
|
|
||||||
- duplicate procedure ids fail at compile time
|
|
||||||
- duplicate session procedure ids fail at compile time
|
|
||||||
- generic state structs expand correctly
|
|
||||||
- generated code routes final frames retry-safely
|
|
||||||
- generated code preserves unmatched inbound packets
|
|
||||||
|
|
||||||
Use `trybuild` for compile-fail macro tests once `unshell-macros` exists.
|
|
||||||
|
|
||||||
## Rollout Plan
|
|
||||||
|
|
||||||
1. Add `Endpoint::take_inbound_matching`.
|
|
||||||
2. Write a manual fake PTY leaf that follows the exact generated shape.
|
|
||||||
3. Add PTY session tests against the manual fake leaf.
|
|
||||||
4. Create `unshell-macros` and `unshell-macros-core`.
|
|
||||||
5. Generate the same shape as the manual fake leaf.
|
|
||||||
6. Port fake PTY tests to the generated leaf.
|
|
||||||
7. Add compile-fail macro tests.
|
|
||||||
8. Implement the real std-only PTY worker in `unshell-leaves` or `unshell-runtime`.
|
|
||||||
9. Add integration tests for the real worker where the platform supports PTYs.
|
|
||||||
|
|
||||||
This avoids writing a macro against an unproven design. First make the generated
|
|
||||||
shape real by hand, then teach the macro to emit it.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should `SessionCtx` expose any raw `Endpoint` access, or only narrow helpers?
|
|
||||||
- Should session stores be `Vec` first, or should fixed-capacity storage be designed immediately?
|
|
||||||
- Should unknown opcodes produce an error packet by default, or should each session type decide?
|
|
||||||
- Should `Open` always carry `reply_path`, or should the packet format eventually add source path?
|
|
||||||
- Should `ProcedureOut` be retry-safe like session output, or are procedures allowed to fail fast?
|
|
||||||
- Should macro-generated leaves expose counters for active sessions and retry queue depth?
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Implement this as static generated code with a narrow context API.
|
|
||||||
|
|
||||||
Do not build a runtime plugin system. Do not give sessions raw access to session
|
|
||||||
stores. Do not let session code bypass generated output flushing. The macro should
|
|
||||||
make the correct routing and hook behavior the easiest path, especially for final
|
|
||||||
frames.
|
|
||||||
|
|
||||||
The first proof point should be fake PTY. If fake PTY works cleanly, real PTY is
|
|
||||||
mostly OS plumbing.
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
+12
-404
@@ -1,406 +1,14 @@
|
|||||||
use alloc::{collections::BTreeMap, vec::Vec};
|
//! 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.
|
||||||
|
|
||||||
use crate::protocol::{EndpointError, HookID, Packet, SessionStatus};
|
mod event;
|
||||||
|
mod key;
|
||||||
|
mod store;
|
||||||
|
mod view;
|
||||||
|
|
||||||
/// Caller-owned view and packet-flow store for interface frontends.
|
pub use event::{InterfaceEvent, InterfaceEventKind};
|
||||||
///
|
pub use key::{ProcedureKey, SessionKey};
|
||||||
/// Generated leaves receive a mutable reference to this store during interface-aware
|
pub use store::InterfaceStore;
|
||||||
/// updates. They decide which leaf/session/procedure keys to touch, but the storage
|
pub use view::{ProcedureView, SessionView, SessionViewStatus};
|
||||||
/// 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) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::Inbound {
|
|
||||||
packet: packet.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.link_packet_event(leaf_id, packet, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::SessionPacketQueued {
|
|
||||||
procedure_id,
|
|
||||||
hook_id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.session_view_mut(leaf_id, procedure_id, hook_id)
|
|
||||||
.events
|
|
||||||
.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
|
||||||
) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::SessionCreated {
|
|
||||||
procedure_id,
|
|
||||||
hook_id,
|
|
||||||
started_ns,
|
|
||||||
finished_ns: self.now_ns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let view = self.session_view_mut(leaf_id, procedure_id, hook_id);
|
|
||||||
view.status = SessionViewStatus::Running;
|
|
||||||
view.events.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
|
||||||
) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::SessionRejected {
|
|
||||||
procedure_id,
|
|
||||||
hook_id,
|
|
||||||
started_ns,
|
|
||||||
finished_ns: self.now_ns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let view = self.session_view_mut(leaf_id, procedure_id, hook_id);
|
|
||||||
view.status = SessionViewStatus::Rejected;
|
|
||||||
view.events.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
|
||||||
) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::SessionUpdated {
|
|
||||||
procedure_id,
|
|
||||||
hook_id,
|
|
||||||
status,
|
|
||||||
started_ns,
|
|
||||||
finished_ns: self.now_ns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let view = self.session_view_mut(leaf_id, procedure_id, hook_id);
|
|
||||||
view.status = SessionViewStatus::from_session_status(status);
|
|
||||||
view.events.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records one procedure call.
|
|
||||||
pub fn record_procedure_call(
|
|
||||||
&mut self,
|
|
||||||
leaf_id: u32,
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
started_ns: Option<u64>,
|
|
||||||
) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::ProcedureCalled {
|
|
||||||
procedure_id,
|
|
||||||
hook_id,
|
|
||||||
started_ns,
|
|
||||||
finished_ns: self.now_ns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.procedure_view_mut(leaf_id, procedure_id)
|
|
||||||
.events
|
|
||||||
.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a packet emitted by leaf logic before route retry handling.
|
|
||||||
pub fn record_outbound_queued(&mut self, leaf_id: u32, packet: &Packet) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::OutboundQueued {
|
|
||||||
packet: packet.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.link_packet_event(leaf_id, packet, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a route attempt for a queued outbound packet.
|
|
||||||
pub fn record_route_attempt(&mut self, leaf_id: u32, packet: &Packet) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::RouteAttempt {
|
|
||||||
packet: packet.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.link_packet_event(leaf_id, packet, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a successful route attempt.
|
|
||||||
pub fn record_route_success(&mut self, leaf_id: u32, packet: &Packet) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::RouteSuccess {
|
|
||||||
packet: packet.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.link_packet_event(leaf_id, packet, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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) {
|
|
||||||
let index = self.push_event(
|
|
||||||
leaf_id,
|
|
||||||
InterfaceEventKind::RouteFailure {
|
|
||||||
packet: packet.clone(),
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.link_packet_event(leaf_id, packet, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reborrows an optional interface store for one helper call.
|
|
||||||
///
|
|
||||||
/// Generated leaf templates pass the same optional store through several helper
|
|
||||||
/// calls in one update. This small function keeps that reborrow explicit and avoids
|
|
||||||
/// every generated call site having to spell out `Option<&mut &mut T>` plumbing.
|
|
||||||
pub fn borrow_store<'a>(
|
|
||||||
store: &'a mut Option<&mut InterfaceStore>,
|
|
||||||
) -> Option<&'a mut InterfaceStore> {
|
|
||||||
store.as_mut().map(|store| &mut **store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable identity for one generated session view.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct SessionKey {
|
|
||||||
pub leaf_id: u32,
|
|
||||||
pub procedure_id: u32,
|
|
||||||
pub hook_id: HookID,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable identity for one generated procedure view.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct ProcedureKey {
|
|
||||||
pub leaf_id: u32,
|
|
||||||
pub procedure_id: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ordered event stored by [`InterfaceStore`].
|
|
||||||
pub struct InterfaceEvent {
|
|
||||||
pub sequence: u64,
|
|
||||||
pub time_ns: Option<u64>,
|
|
||||||
pub leaf_id: u32,
|
|
||||||
pub kind: InterfaceEventKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interface-visible event emitted by generated helpers.
|
|
||||||
pub enum InterfaceEventKind {
|
|
||||||
Inbound {
|
|
||||||
packet: Packet,
|
|
||||||
},
|
|
||||||
SessionPacketQueued {
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
},
|
|
||||||
SessionCreated {
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
started_ns: Option<u64>,
|
|
||||||
finished_ns: Option<u64>,
|
|
||||||
},
|
|
||||||
SessionRejected {
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
started_ns: Option<u64>,
|
|
||||||
finished_ns: Option<u64>,
|
|
||||||
},
|
|
||||||
SessionUpdated {
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
status: SessionStatus,
|
|
||||||
started_ns: Option<u64>,
|
|
||||||
finished_ns: Option<u64>,
|
|
||||||
},
|
|
||||||
ProcedureCalled {
|
|
||||||
procedure_id: u32,
|
|
||||||
hook_id: HookID,
|
|
||||||
started_ns: Option<u64>,
|
|
||||||
finished_ns: Option<u64>,
|
|
||||||
},
|
|
||||||
OutboundQueued {
|
|
||||||
packet: Packet,
|
|
||||||
},
|
|
||||||
RouteAttempt {
|
|
||||||
packet: Packet,
|
|
||||||
},
|
|
||||||
RouteSuccess {
|
|
||||||
packet: Packet,
|
|
||||||
},
|
|
||||||
RouteFailure {
|
|
||||||
packet: Packet,
|
|
||||||
error: EndpointError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Caller-owned render view for one hook-backed session.
|
|
||||||
pub struct SessionView {
|
|
||||||
pub status: SessionViewStatus,
|
|
||||||
pub events: Vec<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionView {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
status: SessionViewStatus::Pending,
|
|
||||||
events: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Caller-owned render view for one one-shot procedure family.
|
|
||||||
pub struct ProcedureView {
|
|
||||||
pub events: Vec<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcedureView {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self { events: Vec::new() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interface lifecycle state for one session view.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SessionViewStatus {
|
|
||||||
Pending,
|
|
||||||
Running,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
Rejected,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionViewStatus {
|
|
||||||
fn from_session_status(status: SessionStatus) -> Self {
|
|
||||||
match status {
|
|
||||||
SessionStatus::Running => Self::Running,
|
|
||||||
SessionStatus::Closing => Self::Closing,
|
|
||||||
SessionStatus::Closed => Self::Closed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ macro_rules! unshell_leaf {
|
|||||||
mut interface: Option<&mut $crate::interface::InterfaceStore>,
|
mut interface: Option<&mut $crate::interface::InterfaceStore>,
|
||||||
) {
|
) {
|
||||||
let leaf_id = $id;
|
let leaf_id = $id;
|
||||||
self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface));
|
self.__unshell_flush_all(endpoint, &mut interface);
|
||||||
|
|
||||||
let Some(local_id) = endpoint.path.last().copied() else {
|
let Some(local_id) = endpoint.path.last().copied() else {
|
||||||
return;
|
return;
|
||||||
@@ -94,7 +94,7 @@ macro_rules! unshell_leaf {
|
|||||||
self.__unshell_dispatch_packet(
|
self.__unshell_dispatch_packet(
|
||||||
endpoint,
|
endpoint,
|
||||||
packet,
|
packet,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
&mut interface,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,18 +103,18 @@ macro_rules! unshell_leaf {
|
|||||||
leaf_id,
|
leaf_id,
|
||||||
&mut self.state,
|
&mut self.state,
|
||||||
&mut self.$session_field,
|
&mut self.$session_field,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
&mut interface,
|
||||||
);
|
);
|
||||||
)*
|
)*
|
||||||
|
|
||||||
self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface));
|
self.__unshell_flush_all(endpoint, &mut interface);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn __unshell_dispatch_packet(
|
fn __unshell_dispatch_packet(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint: &mut $crate::protocol::Endpoint,
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
packet: $crate::protocol::Packet,
|
packet: $crate::protocol::Packet,
|
||||||
mut interface: Option<&mut $crate::interface::InterfaceStore>,
|
interface: &mut Option<&mut $crate::interface::InterfaceStore>,
|
||||||
) {
|
) {
|
||||||
let leaf_id = $id;
|
let leaf_id = $id;
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ macro_rules! unshell_leaf {
|
|||||||
&mut self.$session_field,
|
&mut self.$session_field,
|
||||||
packet,
|
packet,
|
||||||
&mut self.outbox,
|
&mut self.outbox,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
interface,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ macro_rules! unshell_leaf {
|
|||||||
endpoint,
|
endpoint,
|
||||||
packet,
|
packet,
|
||||||
&mut self.outbox,
|
&mut self.outbox,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
interface,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ macro_rules! unshell_leaf {
|
|||||||
fn __unshell_flush_all(
|
fn __unshell_flush_all(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint: &mut $crate::protocol::Endpoint,
|
endpoint: &mut $crate::protocol::Endpoint,
|
||||||
mut interface: Option<&mut $crate::interface::InterfaceStore>,
|
interface: &mut Option<&mut $crate::interface::InterfaceStore>,
|
||||||
) {
|
) {
|
||||||
let leaf_id = $id;
|
let leaf_id = $id;
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ macro_rules! unshell_leaf {
|
|||||||
endpoint,
|
endpoint,
|
||||||
leaf_id,
|
leaf_id,
|
||||||
&mut self.outbox,
|
&mut self.outbox,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
interface,
|
||||||
);
|
);
|
||||||
|
|
||||||
$(
|
$(
|
||||||
@@ -174,7 +174,7 @@ macro_rules! unshell_leaf {
|
|||||||
endpoint,
|
endpoint,
|
||||||
leaf_id,
|
leaf_id,
|
||||||
&mut self.$session_field,
|
&mut self.$session_field,
|
||||||
$crate::interface::borrow_store(&mut interface),
|
interface,
|
||||||
);
|
);
|
||||||
)*
|
)*
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-23
@@ -61,14 +61,14 @@ pub fn dispatch_session<L, S>(
|
|||||||
family: &mut SessionFamily<S::State>,
|
family: &mut SessionFamily<S::State>,
|
||||||
packet: Packet,
|
packet: Packet,
|
||||||
outbox: &mut LeafOutbox,
|
outbox: &mut LeafOutbox,
|
||||||
mut interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) where
|
) where
|
||||||
S: Session<L>,
|
S: Session<L>,
|
||||||
{
|
{
|
||||||
let hook_id = packet.hook_id;
|
let hook_id = packet.hook_id;
|
||||||
let procedure_id = S::PROCEDURE_ID;
|
let procedure_id = S::PROCEDURE_ID;
|
||||||
|
|
||||||
if let Some(store) = crate::interface::borrow_store(&mut interface) {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_inbound(leaf_id, &packet);
|
store.record_inbound(leaf_id, &packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ pub fn dispatch_session<L, S>(
|
|||||||
{
|
{
|
||||||
entry.inbox.push_back(packet);
|
entry.inbox.push_back(packet);
|
||||||
|
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_session_packet_queued(leaf_id, procedure_id, hook_id);
|
store.record_session_packet_queued(leaf_id, procedure_id, hook_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,17 +94,17 @@ pub fn dispatch_session<L, S>(
|
|||||||
SessionInitResult::Created(state) => {
|
SessionInitResult::Created(state) => {
|
||||||
family.entries.push(SessionEntry::new(hook_id, state));
|
family.entries.push(SessionEntry::new(hook_id, state));
|
||||||
|
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_session_created(leaf_id, procedure_id, hook_id, started_ns);
|
store.record_session_created(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SessionInitResult::Rejected => {
|
SessionInitResult::Rejected => {
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SessionInitResult::RejectedWith(packet) => {
|
SessionInitResult::RejectedWith(packet) => {
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns);
|
||||||
store.record_outbound_queued(leaf_id, &packet);
|
store.record_outbound_queued(leaf_id, &packet);
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ pub fn update_session_family<L, S>(
|
|||||||
leaf_id: u32,
|
leaf_id: u32,
|
||||||
leaf: &mut L,
|
leaf: &mut L,
|
||||||
family: &mut SessionFamily<S::State>,
|
family: &mut SessionFamily<S::State>,
|
||||||
mut interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) where
|
) where
|
||||||
S: Session<L>,
|
S: Session<L>,
|
||||||
{
|
{
|
||||||
@@ -138,7 +138,7 @@ pub fn update_session_family<L, S>(
|
|||||||
);
|
);
|
||||||
let status = S::update(leaf, &mut entry.state, &mut entry.inbox, &mut ctx);
|
let status = S::update(leaf, &mut entry.state, &mut entry.inbox, &mut ctx);
|
||||||
|
|
||||||
if let Some(store) = crate::interface::borrow_store(&mut interface) {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_session_update(
|
store.record_session_update(
|
||||||
leaf_id,
|
leaf_id,
|
||||||
S::PROCEDURE_ID,
|
S::PROCEDURE_ID,
|
||||||
@@ -161,13 +161,13 @@ pub fn dispatch_procedure<L, P>(
|
|||||||
endpoint: &mut Endpoint,
|
endpoint: &mut Endpoint,
|
||||||
packet: Packet,
|
packet: Packet,
|
||||||
outbox: &mut LeafOutbox,
|
outbox: &mut LeafOutbox,
|
||||||
mut interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) where
|
) where
|
||||||
P: Procedure<L>,
|
P: Procedure<L>,
|
||||||
{
|
{
|
||||||
let started_ns = interface.as_ref().and_then(|store| store.now_ns());
|
let started_ns = interface.as_ref().and_then(|store| store.now_ns());
|
||||||
|
|
||||||
if let Some(store) = crate::interface::borrow_store(&mut interface) {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_inbound(leaf_id, &packet);
|
store.record_inbound(leaf_id, &packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ pub fn dispatch_procedure<L, P>(
|
|||||||
|
|
||||||
let packets = procedure_out.into_packets();
|
let packets = procedure_out.into_packets();
|
||||||
|
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_procedure_call(leaf_id, P::PROCEDURE_ID, hook_id, started_ns);
|
store.record_procedure_call(leaf_id, P::PROCEDURE_ID, hook_id, started_ns);
|
||||||
|
|
||||||
for packet in &packets {
|
for packet in &packets {
|
||||||
@@ -195,7 +195,7 @@ pub fn flush_leaf_outbox(
|
|||||||
endpoint: &mut Endpoint,
|
endpoint: &mut Endpoint,
|
||||||
leaf_id: u32,
|
leaf_id: u32,
|
||||||
outbox: &mut LeafOutbox,
|
outbox: &mut LeafOutbox,
|
||||||
interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
flush_packet_queue_with_interface(endpoint, leaf_id, &mut outbox.packets, interface)
|
flush_packet_queue_with_interface(endpoint, leaf_id, &mut outbox.packets, interface)
|
||||||
}
|
}
|
||||||
@@ -205,17 +205,12 @@ pub fn flush_session_family<L, S>(
|
|||||||
endpoint: &mut Endpoint,
|
endpoint: &mut Endpoint,
|
||||||
leaf_id: u32,
|
leaf_id: u32,
|
||||||
family: &mut SessionFamily<S::State>,
|
family: &mut SessionFamily<S::State>,
|
||||||
mut interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) where
|
) where
|
||||||
S: Session<L>,
|
S: Session<L>,
|
||||||
{
|
{
|
||||||
for entry in &mut family.entries {
|
for entry in &mut family.entries {
|
||||||
flush_packet_queue_with_interface(
|
flush_packet_queue_with_interface(endpoint, leaf_id, &mut entry.outbox, interface);
|
||||||
endpoint,
|
|
||||||
leaf_id,
|
|
||||||
&mut entry.outbox,
|
|
||||||
crate::interface::borrow_store(&mut interface),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
family
|
family
|
||||||
@@ -232,23 +227,23 @@ pub fn flush_packet_queue_with_interface(
|
|||||||
endpoint: &mut Endpoint,
|
endpoint: &mut Endpoint,
|
||||||
leaf_id: u32,
|
leaf_id: u32,
|
||||||
outbox: &mut PacketQueue,
|
outbox: &mut PacketQueue,
|
||||||
mut interface: Option<&mut InterfaceStore>,
|
interface: &mut Option<&mut InterfaceStore>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
while let Some(packet) = outbox.front().cloned() {
|
while let Some(packet) = outbox.front().cloned() {
|
||||||
if let Some(store) = crate::interface::borrow_store(&mut interface) {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_route_attempt(leaf_id, &packet);
|
store.record_route_attempt(leaf_id, &packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
match endpoint.add_outbound(packet.clone()) {
|
match endpoint.add_outbound(packet.clone()) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Some(store) = crate::interface::borrow_store(&mut interface) {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_route_success(leaf_id, &packet);
|
store.record_route_success(leaf_id, &packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
outbox.pop_front();
|
outbox.pop_front();
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if let Some(store) = interface {
|
if let Some(store) = interface.as_mut() {
|
||||||
store.record_route_failure(leaf_id, &packet, error);
|
store.record_route_failure(leaf_id, &packet, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user