Skip to main content

agentmux_srv/backend/rpc/
engine.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! RPC engine: handles incoming RPC requests, dispatches to handlers,
6//! and manages request/response lifecycle with timeouts and streaming.
7//! Port of Go's pkg/wshutil/wshrpc.go (WshRpc struct + handler dispatch).
8
9
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15
16use tokio::sync::mpsc;
17use uuid::Uuid;
18
19use super::super::rpc_types::{RpcContext, RpcMessage, RpcOpts, COMMAND_EVENT_RECV};
20use super::schema::RpcSchema;
21
22// ---- Constants (match Go) ----
23
24pub const DEFAULT_TIMEOUT_MS: i64 = 5000;
25const RESP_CH_SIZE: usize = 32;
26
27// ---- Handler types ----
28
29/// Result type for RPC handler responses.
30pub type HandlerResult = Result<Option<serde_json::Value>, String>;
31
32/// A boxed async handler function.
33/// Takes the command data and returns either:
34/// - Ok(Some(value)) for a single response
35/// - Ok(None) for no response
36/// - Err(msg) for an error response
37pub type CommandHandler = Box<
38    dyn Fn(serde_json::Value, RpcContext) -> Pin<Box<dyn Future<Output = HandlerResult> + Send>>
39        + Send
40        + Sync,
41>;
42
43/// A streaming handler that returns a channel of responses.
44pub type StreamHandler = Box<
45    dyn Fn(
46            serde_json::Value,
47            RpcContext,
48        )
49            -> Pin<Box<dyn Future<Output = Result<mpsc::Receiver<HandlerResult>, String>> + Send>>
50        + Send
51        + Sync,
52>;
53
54enum Handler {
55    Call(CommandHandler),
56    #[allow(dead_code)]
57    Stream(StreamHandler),
58}
59
60// ---- RPC Response Handler ----
61
62/// Allows an RPC handler to send responses back to the caller.
63/// Matches Go's `RpcResponseHandler`.
64pub struct RpcResponseHandler {
65    engine: Arc<WshRpcEngine>,
66    req_id: String,
67    #[allow(dead_code)]
68    source: String,
69    canceled: AtomicBool,
70    done: AtomicBool,
71}
72
73impl RpcResponseHandler {
74    /// Send a single response (or streaming chunk).
75    /// Set `done` to true for the final response.
76    pub fn send_response(&self, data: Option<serde_json::Value>, done: bool) {
77        if self.done.load(Ordering::Relaxed) {
78            return;
79        }
80        let msg = RpcMessage {
81            resid: self.req_id.clone(),
82            data,
83            cont: !done,
84            ..Default::default()
85        };
86        if done {
87            self.done.store(true, Ordering::Relaxed);
88        }
89        self.engine.send_output(msg);
90    }
91
92    /// Send an error response.
93    pub fn send_error(&self, err: &str) {
94        if self.done.load(Ordering::Relaxed) {
95            return;
96        }
97        self.done.store(true, Ordering::Relaxed);
98        let msg = RpcMessage {
99            resid: self.req_id.clone(),
100            error: err.to_string(),
101            ..Default::default()
102        };
103        self.engine.send_output(msg);
104    }
105
106    /// Check if the request has been canceled.
107    #[allow(dead_code)]
108    pub fn is_canceled(&self) -> bool {
109        self.canceled.load(Ordering::Relaxed)
110    }
111
112    /// Get the source route ID of the request.
113    #[allow(dead_code)]
114    pub fn get_source(&self) -> &str {
115        &self.source
116    }
117
118    /// Mark this handler as canceled.
119    fn cancel(&self) {
120        self.canceled.store(true, Ordering::Relaxed);
121    }
122
123    /// Finalize: send empty done response if not already done.
124    fn finalize(&self) {
125        if self.done.load(Ordering::Relaxed) {
126            return;
127        }
128        self.send_response(None, true);
129    }
130}
131
132// ---- RPC Request Handler (client-side) ----
133
134/// Tracks an outgoing request and collects responses.
135/// Matches Go's `RpcRequestHandler`.
136#[allow(dead_code)]
137pub struct RpcRequestHandler {
138    req_id: String,
139    resp_rx: mpsc::Receiver<RpcMessage>,
140    last_was_cont: bool,
141}
142
143impl RpcRequestHandler {
144    /// Get the next response. Returns None if the stream is done.
145    #[allow(dead_code)]
146    pub async fn next_response(&mut self) -> Option<Result<serde_json::Value, String>> {
147        if !self.last_was_cont && self.req_id.is_empty() {
148            return None;
149        }
150        match self.resp_rx.recv().await {
151            Some(msg) => {
152                self.last_was_cont = msg.cont;
153                if !msg.error.is_empty() {
154                    Some(Err(msg.error))
155                } else {
156                    Some(Ok(msg.data.unwrap_or(serde_json::Value::Null)))
157                }
158            }
159            None => None,
160        }
161    }
162
163    /// Check if the response stream is complete.
164    #[allow(dead_code)]
165    pub fn is_done(&self) -> bool {
166        !self.last_was_cont
167    }
168
169    /// Get the request ID.
170    #[allow(dead_code)]
171    pub fn req_id(&self) -> &str {
172        &self.req_id
173    }
174}
175
176// ---- RPC Engine ----
177
178struct EngineInner {
179    handlers: HashMap<String, Handler>,
180    pending_responses: HashMap<String, mpsc::Sender<RpcMessage>>,
181    active_handlers: HashMap<String, Arc<RpcResponseHandler>>,
182    #[allow(dead_code)]
183    auth_token: String,
184    rpc_context: Option<RpcContext>,
185    /// Request/response types of every command registered through
186    /// [`WshRpcEngine::register_typed`]. Lives beside `handlers` so the
187    /// mapping is a side effect of registration and cannot drift from it.
188    schema: RpcSchema,
189}
190
191/// Core RPC engine: handles incoming RPC requests, dispatches to registered
192/// command handlers, and manages request/response lifecycle.
193///
194/// Port of Go's `WshRpc` from pkg/wshutil/wshrpc.go.
195pub struct WshRpcEngine {
196    inner: Mutex<EngineInner>,
197    output_tx: mpsc::UnboundedSender<RpcMessage>,
198}
199
200impl WshRpcEngine {
201    /// Lock `inner`, recovering from mutex poison instead of propagating
202    /// the panic. A poisoned mutex means a previous handler panicked while
203    /// holding the lock — the data is still consistent for our purposes
204    /// (inserts/removes on HashMaps), so recovery is safe here.
205    fn lock_inner(&self) -> std::sync::MutexGuard<'_, EngineInner> {
206        self.inner.lock().unwrap_or_else(|e| e.into_inner())
207    }
208
209    /// Create a new RPC engine.
210    /// Returns the engine and a receiver for outgoing messages.
211    pub fn new() -> (Arc<Self>, mpsc::UnboundedReceiver<RpcMessage>) {
212        let (output_tx, output_rx) = mpsc::unbounded_channel();
213        let engine = Arc::new(Self {
214            inner: Mutex::new(EngineInner {
215                handlers: HashMap::new(),
216                pending_responses: HashMap::new(),
217                active_handlers: HashMap::new(),
218                auth_token: String::new(),
219                rpc_context: None,
220                schema: RpcSchema::new(),
221            }),
222            output_tx,
223        });
224        (engine, output_rx)
225    }
226
227    /// Install a call handler without touching the schema.
228    ///
229    /// The one path that must not evict: [`Self::register_typed`] records a
230    /// binding and then installs its wrapper, so going through the public
231    /// [`Self::register_handler`] would delete the entry it had just written.
232    fn install_call_handler(&self, command: &str, handler: CommandHandler) {
233        let mut inner = self.lock_inner();
234        inner
235            .handlers
236            .insert(command.to_string(), Handler::Call(handler));
237    }
238
239    /// Register a call handler (single request → single response).
240    ///
241    /// Drops any typed binding previously recorded for `command`: the last
242    /// registration wins for the handler map, so it must win for the schema
243    /// too, or `schema_json()` would advertise a request/response pair the
244    /// live handler no longer implements.
245    pub fn register_handler(&self, command: &str, handler: CommandHandler) {
246        self.lock_inner().schema.remove(command);
247        self.install_call_handler(command, handler);
248    }
249
250    /// Register a call handler from typed request/response values, and
251    /// record the pair in this engine's [`RpcSchema`].
252    ///
253    /// Behaviourally identical to [`Self::register_handler`]: the closure is
254    /// wrapped in exactly the deserialize → run → serialize steps every
255    /// hand-written handler already performs, so migrating one is not
256    /// supposed to change what goes over the wire. What it adds is that the
257    /// `Req`/`Resp` pairing becomes *data* the process can dump, which is
258    /// the prerequisite for generating the frontend's bindings instead of
259    /// hand-maintaining them — see `schema.rs` and
260    /// `docs/specs/SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md`.
261    ///
262    /// Two deliberate differences from the untyped path, both matching what
263    /// the existing handlers do anyway:
264    ///
265    /// - **Deserialization failure is reported as `Err`**, with the command
266    ///   name and serde's message — the same `format!("{cmd}: {e}")` shape
267    ///   the hand-written handlers use.
268    /// - **A response is always sent** (`Ok(Some(..))`). Handlers that must
269    ///   answer with nothing at all keep using `register_handler`; `Resp =
270    ///   ()` serializes to `null`, which is a response, not silence.
271    ///
272    /// Serialization failure is also an `Err` rather than a panic: a
273    /// response type whose `Serialize` fails is a bug, but it is not worth
274    /// taking the connection down for.
275    ///
276    /// Both types must derive `ts_rs::TS`: the schema records each as
277    /// TypeScript, which is what the generated frontend stubs
278    /// (`frontend/app/store/rpc-api/generated.ts`) are built from. A typed
279    /// command whose types have no TypeScript would be one the frontend has
280    /// to describe by hand again.
281    pub fn register_typed<Req, Resp, F, Fut>(&self, command: &'static str, handler: F)
282    where
283        Req: serde::de::DeserializeOwned + ts_rs::TS + Send + 'static,
284        Resp: serde::Serialize + ts_rs::TS + Send + 'static,
285        F: Fn(Req, RpcContext) -> Fut + Send + Sync + 'static,
286        Fut: std::future::Future<Output = Result<Resp, String>> + Send + 'static,
287    {
288        {
289            let mut inner = self.lock_inner();
290            inner.schema.record_typed::<Req, Resp>(command);
291        }
292
293        let handler = std::sync::Arc::new(handler);
294        // install_call_handler, not register_handler: the latter evicts the
295        // schema entry recorded just above.
296        self.install_call_handler(
297            command,
298            Box::new(move |data, ctx| {
299                let handler = handler.clone();
300                Box::pin(async move {
301                    let req: Req = serde_json::from_value(data)
302                        .map_err(|e| format!("{command}: {e}"))?;
303                    let resp = handler(req, ctx).await?;
304                    let value = serde_json::to_value(&resp)
305                        .map_err(|e| format!("{command}: response is not serializable: {e}"))?;
306                    Ok(Some(value))
307                })
308            }),
309        );
310    }
311
312    /// Snapshot of every typed command's request/response pair, in command
313    /// order. Handlers registered through the untyped [`Self::register_handler`]
314    /// are absent — that is what lets the migration proceed one at a time.
315    #[cfg(test)]
316    pub fn schema_json(&self) -> serde_json::Value {
317        self.lock_inner().schema.to_json()
318    }
319
320    /// Number of commands registered through the typed path.
321    #[cfg(test)]
322    pub fn typed_command_count(&self) -> usize {
323        self.lock_inner().schema.len()
324    }
325
326    /// Register a streaming handler (single request → stream of responses).
327    #[allow(dead_code)]
328    pub fn register_stream_handler(&self, command: &str, handler: StreamHandler) {
329        let mut inner = self.lock_inner();
330        // Same eviction as register_handler — a streaming command has no
331        // single typed response to describe.
332        inner.schema.remove(command);
333        inner
334            .handlers
335            .insert(command.to_string(), Handler::Stream(handler));
336    }
337
338    /// Set the authentication token.
339    #[allow(dead_code)]
340    pub fn set_auth_token(&self, token: &str) {
341        let mut inner = self.lock_inner();
342        inner.auth_token = token.to_string();
343    }
344
345    /// Get the authentication token.
346    #[allow(dead_code)]
347    pub fn get_auth_token(&self) -> String {
348        let inner = self.lock_inner();
349        inner.auth_token.clone()
350    }
351
352    /// Set the RPC context.
353    #[allow(dead_code)]
354    pub fn set_rpc_context(&self, ctx: RpcContext) {
355        let mut inner = self.lock_inner();
356        inner.rpc_context = Some(ctx);
357    }
358
359    /// Process an incoming message (from the transport layer).
360    pub fn handle_message(self: &Arc<Self>, msg: RpcMessage) {
361        // Cancel handling
362        if msg.cancel {
363            if !msg.reqid.is_empty() {
364                self.handle_cancel_request(&msg.reqid);
365            }
366            return;
367        }
368
369        // Event handling (special: no response)
370        if msg.command == COMMAND_EVENT_RECV {
371            // Events are handled by the event listener, not via RPC handlers
372            return;
373        }
374
375        // New command (request)
376        if !msg.command.is_empty() {
377            let engine = self.clone();
378            tokio::spawn(async move {
379                engine.handle_request(msg).await;
380            });
381            return;
382        }
383
384        // Response (has resid)
385        if !msg.resid.is_empty() {
386            self.handle_response(msg);
387        }
388    }
389
390    /// Send an RPC command and wait for a single response.
391    #[allow(dead_code)]
392    pub async fn send_command(
393        self: &Arc<Self>,
394        command: &str,
395        data: serde_json::Value,
396        opts: &RpcOpts,
397    ) -> Result<serde_json::Value, String> {
398        let mut handler = self.send_request(command, data, opts)?;
399        match handler.next_response().await {
400            Some(result) => result,
401            None => Err("no response received".to_string()),
402        }
403    }
404
405    /// Send an RPC command and get a request handler for streaming responses.
406    #[allow(dead_code)]
407    pub fn send_request(
408        self: &Arc<Self>,
409        command: &str,
410        data: serde_json::Value,
411        opts: &RpcOpts,
412    ) -> Result<RpcRequestHandler, String> {
413        let req_id = Uuid::new_v4().to_string();
414        let (resp_tx, resp_rx) = mpsc::channel(RESP_CH_SIZE);
415
416        {
417            let mut inner = self.lock_inner();
418            inner
419                .pending_responses
420                .insert(req_id.clone(), resp_tx);
421        }
422
423        let timeout = if opts.timeout > 0 {
424            opts.timeout
425        } else {
426            DEFAULT_TIMEOUT_MS
427        };
428        let route = if opts.route.is_empty() {
429            String::new()
430        } else {
431            opts.route.clone()
432        };
433
434        let msg = RpcMessage {
435            command: command.to_string(),
436            reqid: req_id.clone(),
437            timeout,
438            route,
439            data: Some(data),
440            authtoken: self.get_auth_token(),
441            ..Default::default()
442        };
443        self.send_output(msg);
444
445        Ok(RpcRequestHandler {
446            req_id,
447            resp_rx,
448            last_was_cont: true, // assume more data initially
449        })
450    }
451
452    /// Send a fire-and-forget command (no response expected).
453    #[allow(dead_code)]
454    pub fn send_command_no_response(
455        &self,
456        command: &str,
457        data: serde_json::Value,
458        route: &str,
459    ) {
460        let msg = RpcMessage {
461            command: command.to_string(),
462            data: Some(data),
463            route: route.to_string(),
464            authtoken: self.get_auth_token(),
465            ..Default::default()
466        };
467        self.send_output(msg);
468    }
469
470    // ---- Internal ----
471
472    fn send_output(&self, msg: RpcMessage) {
473        if self.output_tx.send(msg).is_err() {
474            tracing::warn!("[rpc-engine] output channel closed — message dropped");
475        }
476    }
477
478    async fn handle_request(self: Arc<Self>, msg: RpcMessage) {
479        let request_start = std::time::Instant::now();
480        let timeout_ms = if msg.timeout > 0 {
481            msg.timeout
482        } else {
483            DEFAULT_TIMEOUT_MS
484        };
485
486        let handler = Arc::new(RpcResponseHandler {
487            engine: self.clone(),
488            req_id: msg.reqid.clone(),
489            source: msg.source.clone(),
490            canceled: AtomicBool::new(false),
491            done: AtomicBool::new(false),
492        });
493
494        // Register the active handler
495        if !msg.reqid.is_empty() {
496            let mut inner = self.lock_inner();
497            inner
498                .active_handlers
499                .insert(msg.reqid.clone(), handler.clone());
500        }
501
502        let rpc_context = {
503            let inner = self.lock_inner();
504            inner.rpc_context.clone().unwrap_or_default()
505        };
506
507        let data = msg.data.unwrap_or(serde_json::Value::Null);
508        let command = msg.command.clone();
509
510        // Look up handler
511        let has_call;
512        let has_stream;
513        {
514            let inner = self.lock_inner();
515            match inner.handlers.get(&command) {
516                Some(Handler::Call(_)) => {
517                    has_call = true;
518                    has_stream = false;
519                }
520                Some(Handler::Stream(_)) => {
521                    has_call = false;
522                    has_stream = true;
523                }
524                None => {
525                    has_call = false;
526                    has_stream = false;
527                }
528            }
529        }
530
531        let dispatch_elapsed = request_start.elapsed();
532
533        if !has_call && !has_stream {
534            handler.send_error(&format!("unknown command: {}", command));
535            self.cleanup_handler(&msg.reqid);
536            return;
537        }
538
539        let timeout_dur = std::time::Duration::from_millis(timeout_ms as u64);
540
541        if has_call {
542            // Call handler: single response with timeout.
543            // Create the future while holding the lock, then drop the lock before awaiting.
544            let handler_start = std::time::Instant::now();
545            let fut = {
546                let inner = self.lock_inner();
547                match inner.handlers.get(&command) {
548                    Some(Handler::Call(h)) => h(data.clone(), rpc_context.clone()),
549                    _ => Box::pin(async { Err("handler disappeared".to_string()) }),
550                }
551            };
552            let result = tokio::time::timeout(timeout_dur, fut).await;
553            let handler_elapsed = handler_start.elapsed();
554            let total_elapsed = request_start.elapsed();
555
556            // debug, not info: fires on every websocket RPC call, of any kind
557            // — ~27% of an unrotated 406 MB launcher-log mirror on a real
558            // machine (SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29 P1). Default
559            // production filter is info, so this is now suppressed unless
560            // RUST_LOG=debug is set; still there for perf debugging on demand.
561            tracing::debug!(
562                "[rpc-perf] command={} dispatch={:.2}ms handler={:.2}ms total={:.2}ms",
563                command,
564                dispatch_elapsed.as_secs_f64() * 1000.0,
565                handler_elapsed.as_secs_f64() * 1000.0,
566                total_elapsed.as_secs_f64() * 1000.0,
567            );
568
569            match result {
570                Ok(Ok(resp_data)) => handler.send_response(resp_data, true),
571                Ok(Err(err)) => handler.send_error(&err),
572                Err(_) => handler.send_error(&format!("EC-TIME: timeout ({}ms)", timeout_ms)),
573            }
574        } else {
575            // Stream handler: same pattern — build future under lock, await outside.
576            let fut = {
577                let inner = self.lock_inner();
578                match inner.handlers.get(&command) {
579                    Some(Handler::Stream(h)) => h(data.clone(), rpc_context.clone()),
580                    _ => Box::pin(async { Err("handler disappeared".to_string()) }),
581                }
582            };
583            let stream_result = tokio::time::timeout(timeout_dur, fut).await;
584
585            match stream_result {
586                Ok(Ok(mut rx)) => {
587                    // Read streaming responses
588                    loop {
589                        match tokio::time::timeout(timeout_dur, rx.recv()).await {
590                            Ok(Some(Ok(resp_data))) => {
591                                handler.send_response(resp_data, false);
592                            }
593                            Ok(Some(Err(err))) => {
594                                handler.send_error(&err);
595                                break;
596                            }
597                            Ok(None) => {
598                                // Channel closed — stream done
599                                handler.finalize();
600                                break;
601                            }
602                            Err(_) => {
603                                handler.send_error(&format!(
604                                    "EC-TIME: stream timeout ({}ms)",
605                                    timeout_ms
606                                ));
607                                break;
608                            }
609                        }
610                    }
611                }
612                Ok(Err(err)) => handler.send_error(&err),
613                Err(_) => {
614                    handler.send_error(&format!("EC-TIME: timeout ({}ms)", timeout_ms))
615                }
616            }
617        }
618
619        self.cleanup_handler(&msg.reqid);
620    }
621
622    fn handle_response(&self, msg: RpcMessage) {
623        let inner = self.lock_inner();
624        if let Some(tx) = inner.pending_responses.get(&msg.resid) {
625            let is_done = !msg.cont;
626            if tx.try_send(msg.clone()).is_err() {
627                tracing::warn!(resid = %msg.resid, "[rpc-engine] response channel full/closed — reply dropped");
628            }
629            if is_done {
630                drop(inner);
631                let mut inner = self.lock_inner();
632                inner.pending_responses.remove(&msg.resid);
633            }
634        }
635    }
636
637    fn handle_cancel_request(&self, req_id: &str) {
638        let inner = self.lock_inner();
639        if let Some(handler) = inner.active_handlers.get(req_id) {
640            handler.cancel();
641        }
642    }
643
644    fn cleanup_handler(&self, req_id: &str) {
645        if req_id.is_empty() {
646            return;
647        }
648        let mut inner = self.lock_inner();
649        inner.active_handlers.remove(req_id);
650    }
651}
652
653// ====================================================================
654// Tests
655// ====================================================================
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    /// Replacing a typed command through an untyped path clears its schema
662    /// entry (codex P2 on #3074).
663    ///
664    /// The handler map's last-registration-wins rule has to hold for the
665    /// schema too. If it did not, `schema_json()` would keep advertising the
666    /// old request/response pair for a command whose live handler no longer
667    /// accepts it — and a generator reading that would emit a client stub
668    /// that fails at runtime. A stale binding is worse than a missing one.
669    #[tokio::test]
670    async fn replacing_a_typed_command_through_an_untyped_path_clears_its_schema_entry() {
671        #[derive(serde::Deserialize, ts_rs::TS)]
672        struct Req {
673            #[allow(dead_code)]
674            v: i32,
675        }
676        #[derive(serde::Serialize, ts_rs::TS)]
677        struct Resp {
678            ok: bool,
679        }
680
681        let (engine, _rx) = WshRpcEngine::new();
682        engine.register_typed("typed-then-untyped", |_r: Req, _ctx| async move {
683            Ok(Resp { ok: true })
684        });
685        assert_eq!(engine.typed_command_count(), 1, "typed registration should be recorded");
686
687        // Same command, now an untyped handler.
688        engine.register_handler(
689            "typed-then-untyped",
690            Box::new(|_data, _ctx| Box::pin(async move { Ok(Some(serde_json::json!("plain"))) })),
691        );
692        assert_eq!(
693            engine.typed_command_count(),
694            0,
695            "an untyped re-registration must drop the stale typed pair",
696        );
697
698        // And the same via the streaming path, which has no single response
699        // type to describe at all.
700        engine.register_typed("typed-then-stream", |_r: Req, _ctx| async move {
701            Ok(Resp { ok: true })
702        });
703        assert_eq!(engine.typed_command_count(), 1);
704        engine.register_stream_handler(
705            "typed-then-stream",
706            Box::new(|_data, _ctx| {
707                Box::pin(async move {
708                    let (_tx, rx) = mpsc::channel(1);
709                    Ok(rx)
710                })
711            }),
712        );
713        assert_eq!(
714            engine.typed_command_count(),
715            0,
716            "a streaming re-registration must drop the stale typed pair too",
717        );
718    }
719
720    #[tokio::test]
721    async fn register_typed_still_records_after_the_wrapper_is_installed() {
722        // Guards the ordering inside register_typed: it records the binding
723        // and then installs the wrapper. Routing that install through the
724        // public evicting path would delete the entry it had just written,
725        // and this assertion is what catches that.
726        //
727        // NB: keep either registration function's name away from an
728        // immediately-following parenthesised word in prose here.
729        // test/contract/rpc-contract.test.ts scans this file with a regex
730        // that does not skip comments, so such a phrase reads to it as a
731        // real registration whose command it cannot resolve, and fails the
732        // contract extractor. (This comment is deliberately phrased to
733        // describe the trap without containing it.)
734        #[derive(serde::Deserialize, ts_rs::TS)]
735        struct Req {
736            #[allow(dead_code)]
737            v: i32,
738        }
739        #[derive(serde::Serialize, ts_rs::TS)]
740        struct Resp {
741            ok: bool,
742        }
743
744        let (engine, _rx) = WshRpcEngine::new();
745        engine.register_typed("stays-recorded", |_r: Req, _ctx| async move {
746            Ok(Resp { ok: true })
747        });
748        let schema = engine.schema_json();
749        let rows = schema.as_array().unwrap();
750        assert_eq!(rows.len(), 1, "register_typed must not evict its own entry");
751        assert_eq!(rows[0]["command"], "stays-recorded");
752    }
753
754    #[tokio::test]
755    async fn test_register_and_call_handler() {
756        let (engine, mut output_rx) = WshRpcEngine::new();
757
758        engine.register_handler(
759            "echo",
760            Box::new(|data, _ctx| {
761                Box::pin(async move { Ok(Some(data)) })
762            }),
763        );
764
765        let msg = RpcMessage {
766            command: "echo".to_string(),
767            reqid: "req-1".to_string(),
768            data: Some(serde_json::json!({"hello": "world"})),
769            ..Default::default()
770        };
771        engine.handle_message(msg);
772
773        // Collect the response
774        let resp = tokio::time::timeout(
775            std::time::Duration::from_secs(1),
776            output_rx.recv(),
777        )
778        .await
779        .unwrap()
780        .unwrap();
781
782        assert_eq!(resp.resid, "req-1");
783        assert!(!resp.cont);
784        assert_eq!(resp.data, Some(serde_json::json!({"hello": "world"})));
785    }
786
787    #[tokio::test]
788    async fn test_unknown_command_returns_error() {
789        let (engine, mut output_rx) = WshRpcEngine::new();
790
791        let msg = RpcMessage {
792            command: "nonexistent".to_string(),
793            reqid: "req-2".to_string(),
794            ..Default::default()
795        };
796        engine.handle_message(msg);
797
798        let resp = tokio::time::timeout(
799            std::time::Duration::from_secs(1),
800            output_rx.recv(),
801        )
802        .await
803        .unwrap()
804        .unwrap();
805
806        assert_eq!(resp.resid, "req-2");
807        assert!(resp.error.contains("unknown command"));
808    }
809
810    #[tokio::test]
811    async fn test_handler_error_returns_error_response() {
812        let (engine, mut output_rx) = WshRpcEngine::new();
813
814        engine.register_handler(
815            "failme",
816            Box::new(|_data, _ctx| {
817                Box::pin(async move { Err("something went wrong".to_string()) })
818            }),
819        );
820
821        let msg = RpcMessage {
822            command: "failme".to_string(),
823            reqid: "req-3".to_string(),
824            ..Default::default()
825        };
826        engine.handle_message(msg);
827
828        let resp = tokio::time::timeout(
829            std::time::Duration::from_secs(1),
830            output_rx.recv(),
831        )
832        .await
833        .unwrap()
834        .unwrap();
835
836        assert_eq!(resp.error, "something went wrong");
837    }
838
839    #[tokio::test]
840    async fn test_send_command_roundtrip() {
841        let (engine, mut output_rx) = WshRpcEngine::new();
842
843        // Spawn a "server" that echoes responses
844        let engine_clone = engine.clone();
845        tokio::spawn(async move {
846            if let Some(msg) = output_rx.recv().await {
847                // This is the outgoing request — echo it back as a response
848                let resp = RpcMessage {
849                    resid: msg.reqid.clone(),
850                    data: msg.data.clone(),
851                    ..Default::default()
852                };
853                engine_clone.handle_message(resp);
854            }
855        });
856
857        let opts = RpcOpts {
858            timeout: 1000,
859            ..Default::default()
860        };
861        let result = engine
862            .send_command("test", serde_json::json!(42), &opts)
863            .await;
864
865        assert!(result.is_ok());
866        assert_eq!(result.unwrap(), serde_json::json!(42));
867    }
868
869    #[tokio::test]
870    async fn test_stream_handler() {
871        let (engine, mut output_rx) = WshRpcEngine::new();
872
873        engine.register_stream_handler(
874            "counter",
875            Box::new(|_data, _ctx| {
876                Box::pin(async move {
877                    let (tx, rx) = mpsc::channel(8);
878                    tokio::spawn(async move {
879                        for i in 0..3 {
880                            let _ = tx.send(Ok(Some(serde_json::json!(i)))).await;
881                        }
882                        // Channel drops → stream done
883                    });
884                    Ok(rx)
885                })
886            }),
887        );
888
889        let msg = RpcMessage {
890            command: "counter".to_string(),
891            reqid: "req-stream".to_string(),
892            ..Default::default()
893        };
894        engine.handle_message(msg);
895
896        // Collect streaming responses
897        let mut responses = Vec::new();
898        for _ in 0..4 {
899            // 3 data + 1 final empty
900            match tokio::time::timeout(
901                std::time::Duration::from_secs(2),
902                output_rx.recv(),
903            )
904            .await
905            {
906                Ok(Some(resp)) => responses.push(resp),
907                _ => break,
908            }
909        }
910
911        // Should have 3 streaming chunks + 1 final
912        assert!(responses.len() >= 3);
913        // First 3 have cont=true
914        for resp in &responses[..3] {
915            assert!(resp.cont);
916        }
917        // Last one has cont=false (finalize)
918        if responses.len() == 4 {
919            assert!(!responses[3].cont);
920        }
921    }
922
923    // Paused clock: the handler's 10 s sleep and the 12 s wait below pass at
924    // once instead of in real time.
925    #[tokio::test(start_paused = true)]
926    async fn test_cancel_request() {
927        let (engine, mut output_rx) = WshRpcEngine::new();
928
929        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
930        engine.register_handler(
931            "slow",
932            Box::new(move |_data, _ctx| {
933                Box::pin(async move {
934                    // Signal that we started
935                    // (can't move started_tx into closure that's called multiple times)
936                    tokio::time::sleep(std::time::Duration::from_secs(10)).await;
937                    Ok(Some(serde_json::json!("done")))
938                })
939            }),
940        );
941
942        // Send command
943        let msg = RpcMessage {
944            command: "slow".to_string(),
945            reqid: "req-cancel".to_string(),
946            timeout: 10000,
947            ..Default::default()
948        };
949        engine.handle_message(msg);
950
951        // Small delay then send cancel
952        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
953        let cancel_msg = RpcMessage {
954            cancel: true,
955            reqid: "req-cancel".to_string(),
956            ..Default::default()
957        };
958        engine.handle_message(cancel_msg);
959
960        // The handler will still time out or complete, but the cancel flag should be set
961        // Just verify we get a response eventually (timeout response)
962        let resp = tokio::time::timeout(
963            std::time::Duration::from_secs(12),
964            output_rx.recv(),
965        )
966        .await;
967        assert!(resp.is_ok());
968        // Clean up to avoid unused variable warning
969        drop(started_tx);
970        drop(started_rx);
971    }
972
973    #[tokio::test]
974    async fn test_send_command_no_response() {
975        let (engine, mut output_rx) = WshRpcEngine::new();
976
977        engine.send_command_no_response("notify", serde_json::json!({"msg": "hi"}), "");
978
979        let msg = tokio::time::timeout(
980            std::time::Duration::from_millis(100),
981            output_rx.recv(),
982        )
983        .await
984        .unwrap()
985        .unwrap();
986
987        assert_eq!(msg.command, "notify");
988        assert!(msg.reqid.is_empty());
989    }
990
991    #[tokio::test]
992    async fn test_auth_token() {
993        let (engine, _output_rx) = WshRpcEngine::new();
994        assert!(engine.get_auth_token().is_empty());
995
996        engine.set_auth_token("my-secret-token");
997        assert_eq!(engine.get_auth_token(), "my-secret-token");
998    }
999
1000    #[tokio::test]
1001    async fn test_rpc_context() {
1002        let (engine, _output_rx) = WshRpcEngine::new();
1003
1004        let ctx = RpcContext {
1005            client_type: "connserver".to_string(),
1006            blockid: "blk-1".to_string(),
1007            ..Default::default()
1008        };
1009        engine.set_rpc_context(ctx);
1010
1011        // The context is passed to handlers
1012        engine.register_handler(
1013            "checkctx",
1014            Box::new(|_data, ctx| {
1015                Box::pin(async move {
1016                    Ok(Some(serde_json::json!({
1017                        "ctype": ctx.client_type,
1018                        "blockid": ctx.blockid,
1019                    })))
1020                })
1021            }),
1022        );
1023
1024        let msg = RpcMessage {
1025            command: "checkctx".to_string(),
1026            reqid: "req-ctx".to_string(),
1027            ..Default::default()
1028        };
1029        engine.handle_message(msg);
1030
1031        // Output will contain the context
1032        // (tested indirectly through handler dispatch)
1033    }
1034}