Skip to main content

superdoc_sdk/runtime/
process.rs

1use super::{framing::read_frame, policy};
2use crate::{Error, Result};
3use serde_json::{json, Value};
4use std::{
5    collections::{BTreeMap, BTreeSet, HashMap},
6    path::{Path, PathBuf},
7    process::Stdio,
8    sync::{
9        atomic::{AtomicBool, AtomicU8, Ordering},
10        Arc, Mutex, OnceLock,
11    },
12    time::Duration,
13};
14use tokio::{
15    io::{AsyncReadExt, AsyncWriteExt, BufReader},
16    process::{Child, ChildStdin, Command},
17    sync::{broadcast, mpsc, oneshot, watch, Notify, OwnedSemaphorePermit, Semaphore},
18    time::{timeout, Instant},
19};
20
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum HostMode {
23    #[default]
24    Cli,
25    Document,
26}
27
28/// An executable is always spawned directly. Prefix arguments support explicit
29/// development wrappers without shell quoting or command interpolation.
30#[derive(Clone)]
31pub struct HostOptions {
32    pub executable: PathBuf,
33    pub prefix_args: Vec<String>,
34    pub mode: HostMode,
35    pub env: BTreeMap<String, String>,
36    pub startup_timeout: Duration,
37    pub request_timeout: Duration,
38    pub shutdown_timeout: Duration,
39    pub max_pending: usize,
40    pub max_frame_bytes: usize,
41    pub state_directory: Option<PathBuf>,
42}
43
44impl HostOptions {
45    pub fn new(executable: impl Into<PathBuf>) -> Self {
46        let default_ms = policy()["hostDefaultRequestTimeoutMs"]
47            .as_u64()
48            .unwrap_or(30_000);
49        Self {
50            executable: executable.into(),
51            prefix_args: Vec::new(),
52            mode: HostMode::Cli,
53            env: BTreeMap::new(),
54            startup_timeout: Duration::from_secs(10),
55            request_timeout: Duration::from_millis(default_ms),
56            shutdown_timeout: Duration::from_secs(5),
57            max_pending: 64,
58            max_frame_bytes: 64 * 1024 * 1024,
59            state_directory: None,
60        }
61    }
62}
63
64impl std::fmt::Debug for HostOptions {
65    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        formatter
67            .debug_struct("HostOptions")
68            .field("mode", &self.mode)
69            .field("max_pending", &self.max_pending)
70            .field("max_frame_bytes", &self.max_frame_bytes)
71            .finish_non_exhaustive()
72    }
73}
74
75#[derive(Default)]
76pub(crate) struct CallState {
77    // 0: queued, 1: committed to the writer, 2: cancelled before transmission.
78    // The compare-exchange is the linearization point for cancellation.
79    phase: AtomicU8,
80    written: AtomicBool,
81    asset: Mutex<Option<tempfile::TempPath>>,
82}
83impl CallState {
84    pub(crate) fn with_asset(asset: tempfile::TempPath) -> Arc<Self> {
85        Arc::new(Self {
86            asset: Mutex::new(Some(asset)),
87            ..Self::default()
88        })
89    }
90    pub(crate) fn cancel_before_send(&self) -> bool {
91        self.phase
92            .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire)
93            != Err(1)
94    }
95}
96
97struct CancelGuard {
98    state: Arc<CallState>,
99    retire: Option<Arc<Notify>>,
100    disarmed: bool,
101}
102impl Drop for CancelGuard {
103    fn drop(&mut self) {
104        if !self.disarmed
105            && self
106                .state
107                .phase
108                .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire)
109                == Err(1)
110        {
111            if let Some(stop) = &self.retire {
112                stop.notify_one();
113            }
114        }
115    }
116}
117
118type Sessions = Arc<Mutex<BTreeMap<String, Arc<AtomicU8>>>>;
119
120struct Request {
121    method: String,
122    params: Value,
123    request_timeout: Option<Duration>,
124    watchdog: Duration,
125    mutates: bool,
126    abandon_open: bool,
127    state: Arc<CallState>,
128    reply: oneshot::Sender<Result<Value>>,
129    permit: OwnedSemaphorePermit,
130}
131
132struct Pending {
133    reply: oneshot::Sender<Result<Value>>,
134    state: Arc<CallState>,
135    deadline: Instant,
136    mutates: bool,
137    abandon_open: bool,
138    _permit: OwnedSemaphorePermit,
139}
140
141struct Write {
142    id: u64,
143    frame: Vec<u8>,
144    state: Arc<CallState>,
145}
146
147enum Event {
148    Frame(Value),
149    Failed(Error),
150    Skipped(u64),
151}
152
153enum Control {
154    Request(Request),
155    Shutdown,
156}
157
158struct Connection {
159    tx: mpsc::Sender<Control>,
160    permits: Arc<Semaphore>,
161    closed: Arc<AtomicBool>,
162    stop: Arc<Notify>,
163    features: OnceLock<BTreeSet<String>>,
164    notifications: Mutex<broadcast::Receiver<Value>>,
165    state_directory: PathBuf,
166    options: HostOptions,
167    termination: watch::Receiver<Option<Result<()>>>,
168    sessions: Sessions,
169}
170
171impl Drop for Connection {
172    fn drop(&mut self) {
173        self.stop.notify_one();
174    }
175}
176
177#[derive(Clone)]
178pub(crate) struct Transport(Arc<Connection>);
179
180impl Transport {
181    pub async fn connect(options: HostOptions) -> Result<Self> {
182        if options.executable.as_os_str().is_empty()
183            || options.max_pending == 0
184            || options.max_frame_bytes == 0
185            || options.startup_timeout.is_zero()
186            || options.request_timeout.is_zero()
187            || options.shutdown_timeout.is_zero()
188            || options.max_pending > 1024
189            || [
190                options.startup_timeout,
191                options.request_timeout,
192                options.shutdown_timeout,
193            ]
194            .iter()
195            .any(|duration| duration.as_millis() == 0 || *duration > Duration::from_secs(86400))
196        {
197            return Err(Error::new(
198                "INVALID_ARGUMENT",
199                "host paths, limits and timeouts must be non-empty and positive",
200            ));
201        }
202        let (directory, owned) = match &options.state_directory {
203            Some(path) => {
204                std::fs::create_dir_all(path).map_err(|_| {
205                    Error::new(
206                        "STATE_DIRECTORY_ERROR",
207                        "could not prepare the configured state directory",
208                    )
209                })?;
210                (path.clone(), false)
211            }
212            None => (
213                tempfile::Builder::new()
214                    .prefix("superdoc-rust-")
215                    .tempdir()
216                    .map_err(|_| {
217                        Error::new(
218                            "STATE_DIRECTORY_ERROR",
219                            "could not create an SDK state directory",
220                        )
221                    })?
222                    .keep(),
223                true,
224            ),
225        };
226        let mut command = Command::new(&options.executable);
227        command
228            .args(&options.prefix_args)
229            .envs(&options.env)
230            .env("SUPERDOC_CLI_STATE_DIR", &directory)
231            .stdin(Stdio::piped())
232            .stdout(Stdio::piped())
233            .stderr(Stdio::piped())
234            .kill_on_drop(true);
235        if options.mode == HostMode::Cli {
236            // A process-wide explicit timeout disables native save/replace.
237            // Negotiate per-request deadlines for eligible methods instead.
238            command.args(["host", "--stdio"]);
239        }
240        let mut child = match command.spawn() {
241            Ok(child) => child,
242            Err(_) => {
243                if owned {
244                    let _ = std::fs::remove_dir_all(&directory);
245                }
246                return Err(Error::new(
247                    "HOST_SPAWN_FAILED",
248                    "could not start the configured SuperDoc host",
249                ));
250            }
251        };
252        let stdin = child.stdin.take().expect("piped stdin");
253        let stdout = child.stdout.take().expect("piped stdout");
254        let stderr = child.stderr.take().expect("piped stderr");
255        let (tx, rx) = mpsc::channel(options.max_pending);
256        let (write_tx, write_rx) = mpsc::channel(options.max_pending);
257        let (event_tx, event_rx) = mpsc::channel(2);
258        let (notifications, notification_receiver) = broadcast::channel(64);
259        let (termination_tx, termination) = watch::channel(None);
260        let stop = Arc::new(Notify::new());
261        let closed = Arc::new(AtomicBool::new(false));
262        let sessions = Arc::new(Mutex::new(BTreeMap::new()));
263        let reader_events = event_tx.clone();
264        let limit = options.max_frame_bytes;
265        let reader = tokio::spawn(async move {
266            let mut input = BufReader::new(stdout);
267            loop {
268                let event = match read_frame(&mut input, limit).await {
269                    Ok(Some(frame)) => Event::Frame(frame),
270                    Ok(None) => {
271                        Event::Failed(Error::new("HOST_DISCONNECTED", "host stdout closed"))
272                    }
273                    Err(error) => Event::Failed(error),
274                };
275                let failed = matches!(event, Event::Failed(_));
276                if reader_events.send(event).await.is_err() || failed {
277                    break;
278                }
279            }
280        });
281        let writer = tokio::spawn(write_frames(stdin, write_rx, event_tx));
282        let stderr_reader = tokio::spawn(async move {
283            let mut stderr = stderr;
284            let mut bytes = [0u8; 4096];
285            // Drain without logging arbitrary document data or authentication.
286            while let Ok(count) = stderr.read(&mut bytes).await {
287                if count == 0 {
288                    break;
289                }
290            }
291        });
292        let transport = Self(Arc::new(Connection {
293            tx,
294            permits: Arc::new(Semaphore::new(options.max_pending)),
295            closed: closed.clone(),
296            stop: stop.clone(),
297            features: OnceLock::new(),
298            notifications: Mutex::new(notification_receiver),
299            state_directory: directory.clone(),
300            options: options.clone(),
301            termination,
302            sessions: sessions.clone(),
303        }));
304        tokio::spawn(run_actor(
305            child,
306            rx,
307            write_tx,
308            event_rx,
309            stop,
310            closed,
311            notifications,
312            directory,
313            owned,
314            options.shutdown_timeout,
315            reader,
316            writer,
317            stderr_reader,
318            termination_tx,
319            sessions,
320        ));
321        let capabilities = transport
322            .request(
323                "host.capabilities",
324                json!({}),
325                None,
326                options.startup_timeout,
327                false,
328                false,
329            )
330            .await;
331        let features = capabilities.and_then(|value| validate_capabilities(&value, options.mode));
332        match features {
333            Ok(features) => {
334                let _ = transport.0.features.set(features);
335                Ok(transport)
336            }
337            Err(error) => {
338                let _ = transport.shutdown().await;
339                Err(Error {
340                    code: "HOST_HANDSHAKE_FAILED".to_owned(),
341                    ..error
342                })
343            }
344        }
345    }
346
347    pub fn features(&self) -> &BTreeSet<String> {
348        self.0.features.get().expect("completed handshake")
349    }
350    pub fn options(&self) -> &HostOptions {
351        &self.0.options
352    }
353    pub fn is_closed(&self) -> bool {
354        self.0.closed.load(Ordering::Acquire)
355    }
356    pub fn state_directory(&self) -> &Path {
357        &self.0.state_directory
358    }
359    pub fn subscribe(&self) -> broadcast::Receiver<Value> {
360        self.0
361            .notifications
362            .lock()
363            .expect("SDK notification lock")
364            .resubscribe()
365    }
366    pub fn session_state(&self, id: &str) -> Arc<AtomicU8> {
367        self.0
368            .sessions
369            .lock()
370            .expect("SDK session lock")
371            .entry(id.to_owned())
372            .or_insert_with(|| Arc::new(AtomicU8::new(0)))
373            .clone()
374    }
375    pub fn has_session(&self, id: &str) -> bool {
376        self.0
377            .sessions
378            .lock()
379            .expect("SDK session lock")
380            .get(id)
381            .is_some_and(|state| state.load(Ordering::Acquire) < 2)
382    }
383    pub fn remove_session(&self, id: &str, state: &Arc<AtomicU8>) {
384        let mut sessions = self.0.sessions.lock().expect("SDK session lock");
385        if sessions
386            .get(id)
387            .is_some_and(|current| Arc::ptr_eq(current, state))
388        {
389            sessions.remove(id);
390        }
391    }
392
393    pub async fn request(
394        &self,
395        method: &str,
396        params: Value,
397        request_timeout: Option<Duration>,
398        watchdog: Duration,
399        mutates: bool,
400        abandon_open: bool,
401    ) -> Result<Value> {
402        self.request_tracked(
403            method,
404            params,
405            request_timeout,
406            watchdog,
407            mutates,
408            abandon_open,
409            Arc::new(CallState::default()),
410        )
411        .await
412    }
413
414    #[allow(clippy::too_many_arguments)]
415    pub(crate) async fn request_tracked(
416        &self,
417        method: &str,
418        params: Value,
419        request_timeout: Option<Duration>,
420        watchdog: Duration,
421        mutates: bool,
422        abandon_open: bool,
423        state: Arc<CallState>,
424    ) -> Result<Value> {
425        for duration in [Some(watchdog), request_timeout].into_iter().flatten() {
426            if duration.as_millis() == 0 || duration > Duration::from_secs(86400 + 5) {
427                return Err(Error::new(
428                    "INVALID_ARGUMENT",
429                    "request timeouts must be between one millisecond and one day",
430                ));
431            }
432        }
433        if self.is_closed() {
434            return Err(Error::new("HOST_DISCONNECTED", "host connection is closed"));
435        }
436        let permit = self
437            .0
438            .permits
439            .clone()
440            .try_acquire_owned()
441            .map_err(|_| Error::new("QUEUE_FULL", "host request capacity is exhausted"))?;
442        let (reply, result) = oneshot::channel();
443        let mut guard = CancelGuard {
444            state: state.clone(),
445            retire: abandon_open.then(|| self.0.stop.clone()),
446            disarmed: false,
447        };
448        self.0
449            .tx
450            .send(Control::Request(Request {
451                method: method.to_owned(),
452                params,
453                request_timeout,
454                watchdog,
455                mutates,
456                abandon_open,
457                state,
458                reply,
459                permit,
460            }))
461            .await
462            .map_err(|_| Error::new("HOST_DISCONNECTED", "host connection is closed"))?;
463        let result = result.await.unwrap_or_else(|_| {
464            Err(Error::new(
465                "HOST_DISCONNECTED",
466                "host connection stopped before replying",
467            ))
468        });
469        guard.disarmed = true;
470        result
471    }
472
473    pub async fn shutdown(&self) -> Result<()> {
474        if !self.is_closed() {
475            let _ = self.0.tx.send(Control::Shutdown).await;
476        }
477        let mut termination = self.0.termination.clone();
478        loop {
479            if let Some(result) = termination.borrow().clone() {
480                return result;
481            }
482            termination.changed().await.map_err(|_| {
483                Error::new(
484                    "HOST_SHUTDOWN_FAILED",
485                    "host cleanup task stopped before confirming process exit",
486                )
487            })?;
488        }
489    }
490}
491
492fn validate_capabilities(value: &Value, mode: HostMode) -> Result<BTreeSet<String>> {
493    if value["protocolVersion"] != policy()["protocolVersion"] {
494        return Err(Error::new(
495            "HOST_HANDSHAKE_FAILED",
496            "host protocol version is incompatible",
497        ));
498    }
499    let values = value["features"]
500        .as_array()
501        .ok_or_else(|| Error::new("HOST_HANDSHAKE_FAILED", "host capabilities were malformed"))?;
502    let features: Option<BTreeSet<_>> = values
503        .iter()
504        .map(|value| value.as_str().map(str::to_owned))
505        .collect();
506    let features = features
507        .ok_or_else(|| Error::new("HOST_HANDSHAKE_FAILED", "host features must be strings"))?;
508    let required = if mode == HostMode::Cli {
509        "cliRequiredFeatures"
510    } else {
511        "documentRequiredFeatures"
512    };
513    let expected = policy()[required]
514        .as_array()
515        .ok_or_else(|| Error::new("HOST_PROTOCOL_ERROR", "generated host policy is missing"))?;
516    if expected
517        .iter()
518        .any(|feature| !features.contains(feature.as_str().unwrap_or("")))
519    {
520        return Err(Error::new(
521            "HOST_HANDSHAKE_FAILED",
522            "host lacks a required SDK capability",
523        ));
524    }
525    Ok(features)
526}
527
528async fn write_frames(
529    mut stdin: ChildStdin,
530    mut commands: mpsc::Receiver<Write>,
531    events: mpsc::Sender<Event>,
532) {
533    while let Some(command) = commands.recv().await {
534        if command
535            .state
536            .phase
537            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
538            .is_err()
539        {
540            if events.send(Event::Skipped(command.id)).await.is_err() {
541                break;
542            }
543            continue;
544        }
545        if stdin.write_all(&command.frame).await.is_err() {
546            let _ = events
547                .send(Event::Failed(Error::new(
548                    "HOST_DISCONNECTED",
549                    "could not write a complete host request",
550                )))
551                .await;
552            break;
553        }
554        command.state.written.store(true, Ordering::Release);
555    }
556}
557
558#[allow(clippy::too_many_arguments)]
559async fn run_actor(
560    mut child: Child,
561    mut commands: mpsc::Receiver<Control>,
562    writes: mpsc::Sender<Write>,
563    mut events: mpsc::Receiver<Event>,
564    stop: Arc<Notify>,
565    closed: Arc<AtomicBool>,
566    notifications: broadcast::Sender<Value>,
567    directory: PathBuf,
568    owned: bool,
569    shutdown_timeout: Duration,
570    reader: tokio::task::JoinHandle<()>,
571    writer: tokio::task::JoinHandle<()>,
572    stderr: tokio::task::JoinHandle<()>,
573    termination: watch::Sender<Option<Result<()>>>,
574    sessions: Sessions,
575) {
576    let mut pending: HashMap<u64, Pending> = HashMap::new();
577    let mut next_id = 1u64;
578    let mut shutdown_id = None;
579    let mut shutdown_deadline = None;
580    let mut failure = Error::new("HOST_DISCONNECTED", "host connection stopped");
581    loop {
582        let next_deadline = pending
583            .values()
584            .map(|request| request.deadline)
585            .chain(shutdown_deadline)
586            .min()
587            .unwrap_or_else(|| Instant::now() + Duration::from_secs(86400));
588        tokio::select! {
589            _=stop.notified()=>break,
590            _=child.wait()=>break,
591            _=tokio::time::sleep_until(next_deadline)=>{
592                if shutdown_deadline.is_some_and(|deadline|deadline<=Instant::now()){failure=Error::new("TIMEOUT","host shutdown deadline expired");break;}
593                let expired:Vec<_>=pending.iter().filter_map(|(id,request)|(request.deadline<=Instant::now()).then_some(*id)).collect();
594                let mut sent_timeout=false;
595                for id in expired {
596                    let request=pending.get(&id).expect("pending deadline");
597                    if request.state.phase.compare_exchange(0,2,Ordering::AcqRel,Ordering::Acquire).is_err()
598                        && request.state.phase.load(Ordering::Acquire)==1 {sent_timeout=true;break;}
599                    if let Some(request)=pending.remove(&id) {let _=request.reply.send(Err(Error::new("TIMEOUT","request expired before transmission")));}
600                }
601                if sent_timeout {failure=Error::new("TIMEOUT","host response watchdog expired");break;}
602            }
603            control=commands.recv(),if shutdown_id.is_none()=>{
604                match control {
605                    None=>break,
606                    Some(Control::Request(request))=>{
607                        if request.state.phase.load(Ordering::Acquire)==2 {continue;}
608                        let id=next_id;next_id+=1;
609                        let mut frame=json!({"jsonrpc":"2.0","id":id,"method":request.method,"params":request.params});
610                        if let Some(value)=request.request_timeout {frame["requestTimeoutMs"]=json!(value.as_millis() as u64);}
611                        let mut bytes=serde_json::to_vec(&frame).expect("serializable JSON value");bytes.push(b'\n');
612                        if bytes.len()>64*1024*1024 {let _=request.reply.send(Err(Error::new("PAYLOAD_TOO_LARGE","host request exceeded the byte limit")));continue;}
613                        let state=request.state.clone();
614                        pending.insert(id,Pending{reply:request.reply,state:request.state,deadline:Instant::now()+request.watchdog,mutates:request.mutates,abandon_open:request.abandon_open,_permit:request.permit});
615                        match writes.try_send(Write{id,frame:bytes,state}) {
616                            Ok(())=>{},
617                            Err(mpsc::error::TrySendError::Full(_))=>{
618                                if let Some(request)=pending.remove(&id) {let _=request.state.phase.compare_exchange(0,2,Ordering::AcqRel,Ordering::Acquire);let _=request.reply.send(Err(Error::new("QUEUE_FULL","host writer capacity is exhausted")));}
619                            }
620                            Err(mpsc::error::TrySendError::Closed(_))=>{failure=Error::new("HOST_DISCONNECTED","host writer stopped");break;}
621                        }
622                    }
623                    Some(Control::Shutdown)=>{
624                        closed.store(true,Ordering::Release);
625                        let id=next_id;next_id+=1;shutdown_id=Some(id);shutdown_deadline=Some(Instant::now()+shutdown_timeout);
626                        let mut frame=serde_json::to_vec(&json!({"jsonrpc":"2.0","id":id,"method":"host.shutdown","params":{}})).expect("shutdown frame");frame.push(b'\n');
627                        if writes.try_send(Write{id,frame,state:Arc::new(CallState::default())}).is_err(){break;}
628                    }
629                }
630            }
631            event=events.recv()=>{
632                match event {
633                    None=>break,
634                    Some(Event::Failed(error))=>{failure=error;break;},
635                    Some(Event::Skipped(id))=>{pending.remove(&id);},
636                    Some(Event::Frame(frame))=>{
637                        if frame["jsonrpc"]!="2.0" {failure=Error::new("HOST_PROTOCOL_ERROR","host sent an invalid JSON-RPC version");break;}
638                        if frame.get("id").is_none() && frame["method"].is_string(){
639                            if frame["method"]=="event.sessionClosed" {
640                                if let Some(id)=frame["params"]["sessionId"].as_str(){if let Some(state)=sessions.lock().expect("SDK session lock").get(id){state.store(2,Ordering::Release);}}
641                            }
642                            let _=notifications.send(frame);continue;
643                        }
644                        let Some(id)=frame["id"].as_u64() else {failure=Error::new("HOST_PROTOCOL_ERROR","host response ID was invalid");break;};
645                        if Some(id)==shutdown_id {break;}
646                        let Some(request)=pending.remove(&id) else {failure=Error::new("HOST_PROTOCOL_ERROR","host returned an unknown or duplicate response ID");break;};
647                        let result=decode_response(frame);
648                        if request.abandon_open {
649                            if let Ok(value)=&result {
650                                if let Some(id)=value["data"]["contextId"].as_str().or_else(||value["sessionId"].as_str()) {
651                                    let mut sessions=sessions.lock().expect("SDK session lock");
652                                    if sessions.get(id).is_none_or(|state|state.load(Ordering::Acquire)>=2){sessions.insert(id.to_owned(),Arc::new(AtomicU8::new(0)));}
653                                }
654                            }
655                        }
656                        if request.abandon_open && request.reply.is_closed() && request.state.phase.load(Ordering::Acquire)==1 {
657                            failure=Error::new("HOST_DISCONNECTED","a cancelled open retired the host to avoid leaking a session");break;
658                        }
659                        let _=request.reply.send(result.map_err(|mut error|{error.outcome_unknown=request.mutates && ["TIMEOUT","HOST_PROTOCOL_ERROR"].contains(&error.code.as_str());error}));
660                    }
661                }
662            }
663        }
664    }
665    closed.store(true, Ordering::Release);
666    for state in sessions.lock().expect("SDK session lock").values() {
667        if state.load(Ordering::Acquire) < 2 {
668            state.store(3, Ordering::Release);
669        }
670    }
671    drop(writes);
672    if shutdown_id.is_none() || failure.code == "TIMEOUT" {
673        let _ = child.start_kill();
674    }
675    let reaped = match timeout(shutdown_timeout, child.wait()).await {
676        Ok(Ok(_)) => true,
677        _ => {
678            let _ = child.start_kill();
679            matches!(timeout(shutdown_timeout, child.wait()).await, Ok(Ok(_)))
680        }
681    };
682    reader.abort();
683    writer.abort();
684    stderr.abort();
685    let _ = reader.await;
686    let _ = writer.await;
687    let _ = stderr.await;
688    for (_, request) in pending {
689        if !reaped {
690            if let Some(asset) = request.state.asset.lock().expect("SDK asset lock").take() {
691                let _ = asset.keep();
692            }
693        }
694        let mut error = failure.clone();
695        error.outcome_unknown = request.mutates && request.state.phase.load(Ordering::Acquire) == 1;
696        let _ = request.reply.send(Err(error));
697    }
698    // Requests accepted into the bounded channel but not committed to the
699    // writer have a known unexecuted outcome.
700    while let Ok(control) = commands.try_recv() {
701        match control {
702            Control::Request(request) => {
703                let _ = request.reply.send(Err(failure.clone()));
704            }
705            Control::Shutdown => {}
706        }
707    }
708    if owned && reaped {
709        let _ = std::fs::remove_dir_all(&directory);
710    }
711    termination.send_replace(Some(if reaped {
712        Ok(())
713    } else {
714        Err(Error::new(
715            "HOST_SHUTDOWN_FAILED",
716            "host process exit could not be confirmed; state was retained",
717        ))
718    }));
719}
720
721fn decode_response(frame: Value) -> Result<Value> {
722    if frame.get("result").is_some() == frame.get("error").is_some() {
723        return Err(Error::new(
724            "HOST_PROTOCOL_ERROR",
725            "host response must contain exactly one result or error",
726        ));
727    }
728    if let Some(error) = frame.get("error") {
729        let code = error["code"]
730            .as_i64()
731            .ok_or_else(|| Error::new("HOST_PROTOCOL_ERROR", "host error code was invalid"))?;
732        if !error["message"].is_string() {
733            return Err(Error::new(
734                "HOST_PROTOCOL_ERROR",
735                "host error message was invalid",
736            ));
737        }
738        let data = &error["data"];
739        let sdk_code = if code == -32011 {
740            "TIMEOUT"
741        } else {
742            data["cliCode"]
743                .as_str()
744                .or_else(|| data["domainCode"].as_str())
745                .unwrap_or("HOST_ERROR")
746        };
747        let mut result = Error::new(sdk_code, "SuperDoc host rejected the operation");
748        result.rpc_code = Some(code);
749        result.details = data.get("details").cloned().map(Box::new);
750        result.host_message = error["message"].as_str().map(Into::into);
751        Err(result)
752    } else {
753        Ok(frame["result"].clone())
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn host(mode: &str, log: &Path) -> HostOptions {
762        let mut options = HostOptions::new(
763            std::env::var("SUPERDOC_SDK_TEST_NODE").unwrap_or_else(|_| "node".to_owned()),
764        );
765        let file = std::env::var_os("SUPERDOC_SDK_TEST_HOST")
766            .map(PathBuf::from)
767            .unwrap_or_else(|| {
768                PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/host.mjs")
769            });
770        options.prefix_args.push(file.to_str().unwrap().to_owned());
771        options
772            .env
773            .insert("SUPERDOC_RUST_TEST_MODE".to_owned(), mode.to_owned());
774        options.env.insert(
775            "SUPERDOC_RUST_TEST_LOG".to_owned(),
776            log.to_str().unwrap().to_owned(),
777        );
778        options.shutdown_timeout = Duration::from_millis(250);
779        options
780    }
781
782    async fn wait_for(mut condition: impl FnMut() -> bool) {
783        timeout(Duration::from_secs(5), async {
784            while !condition() {
785                tokio::time::sleep(Duration::from_millis(5)).await;
786            }
787        })
788        .await
789        .expect("controlled host barrier was not reached");
790    }
791
792    #[tokio::test]
793    async fn correlates_out_of_order_replies() {
794        let directory = tempfile::tempdir().unwrap();
795        let transport = Transport::connect(host("reorder", &directory.path().join("requests")))
796            .await
797            .unwrap();
798        let a = transport.request(
799            "cli.invoke",
800            json!({"argv":["first"]}),
801            None,
802            Duration::from_secs(5),
803            false,
804            false,
805        );
806        let b = transport.request(
807            "cli.invoke",
808            json!({"argv":["second"]}),
809            None,
810            Duration::from_secs(5),
811            false,
812            false,
813        );
814        let (a, b) = tokio::join!(a, b);
815        assert_eq!(a.unwrap()["data"]["marker"], "first");
816        assert_eq!(b.unwrap()["data"]["marker"], "second");
817        transport.shutdown().await.unwrap();
818    }
819
820    #[tokio::test]
821    async fn watchdog_and_disconnection_preserve_uncertain_sent_mutation() {
822        for mode in ["hang", "exit", "bad-frame", "unknown-id"] {
823            let directory = tempfile::tempdir().unwrap();
824            let log = directory.path().join("requests");
825            let transport = Transport::connect(host(mode, &log)).await.unwrap();
826            let error = transport
827                .request(
828                    "cli.invoke",
829                    json!({"argv":["mutation"]}),
830                    None,
831                    Duration::from_secs(1),
832                    true,
833                    false,
834                )
835                .await
836                .unwrap_err();
837            assert!(
838                error.outcome_unknown,
839                "{mode} must not claim a sent mutation was undone"
840            );
841            assert!(matches!(
842                error.code.as_str(),
843                "TIMEOUT" | "HOST_DISCONNECTED" | "HOST_PROTOCOL_ERROR"
844            ));
845            transport.shutdown().await.unwrap();
846            assert_eq!(
847                std::fs::read_to_string(log)
848                    .unwrap()
849                    .matches("mutation")
850                    .count(),
851                1,
852                "a sent mutation must never be replayed"
853            );
854        }
855    }
856
857    #[tokio::test]
858    async fn cancelled_sent_open_retires_host() {
859        let directory = tempfile::tempdir().unwrap();
860        let log = directory.path().join("requests");
861        let transport = Transport::connect(host("hang", &log)).await.unwrap();
862        let state_directory = transport.state_directory().to_path_buf();
863        let copy = transport.clone();
864        let task = tokio::spawn(async move {
865            copy.request(
866                "cli.invoke",
867                json!({"argv":["open"]}),
868                None,
869                Duration::from_secs(30),
870                true,
871                true,
872            )
873            .await
874        });
875        wait_for(|| std::fs::read_to_string(&log).is_ok_and(|log| log.contains("cli.invoke")))
876            .await;
877        task.abort();
878        let _ = task.await;
879        transport.shutdown().await.unwrap();
880        assert!(!state_directory.exists());
881    }
882
883    #[tokio::test]
884    async fn queued_cancellation_sends_no_frame_and_releases_capacity() {
885        let directory = tempfile::tempdir().unwrap();
886        let log = directory.path().join("requests");
887        let paused = directory.path().join("paused");
888        let resume = directory.path().join("resume");
889        let mut options = host("backpressure", &log);
890        options.max_pending = 2;
891        options.env.insert(
892            "SUPERDOC_RUST_TEST_PAUSED".to_owned(),
893            paused.to_str().unwrap().to_owned(),
894        );
895        options.env.insert(
896            "SUPERDOC_RUST_TEST_RESUME".to_owned(),
897            resume.to_str().unwrap().to_owned(),
898        );
899        let transport = Transport::connect(options).await.unwrap();
900        wait_for(|| paused.exists()).await;
901        let first_state = Arc::new(CallState::default());
902        let (reply, result) = oneshot::channel();
903        let permit = transport.0.permits.clone().try_acquire_owned().unwrap();
904        transport
905            .0
906            .tx
907            .send(Control::Request(Request {
908                method: "cli.invoke".to_owned(),
909                params: json!({"argv":["large"],"padding":"x".repeat(16*1024*1024)}),
910                request_timeout: None,
911                watchdog: Duration::from_secs(20),
912                mutates: false,
913                abandon_open: false,
914                state: first_state.clone(),
915                reply,
916                permit,
917            }))
918            .await
919            .unwrap_or_else(|_| panic!("host command channel closed"));
920        wait_for(|| first_state.phase.load(Ordering::Acquire) == 1).await;
921        assert!(
922            !first_state.written.load(Ordering::Acquire),
923            "fixture must hold the writer inside the large frame"
924        );
925        let copy = transport.clone();
926        let cancelled = tokio::spawn(async move {
927            copy.request(
928                "cli.invoke",
929                json!({"argv":["must-not-send"]}),
930                None,
931                Duration::from_secs(10),
932                false,
933                false,
934            )
935            .await
936        });
937        wait_for(|| transport.0.permits.available_permits() == 0).await;
938        cancelled.abort();
939        let _ = cancelled.await;
940        std::fs::write(resume, b"resume").unwrap();
941        result.await.unwrap().unwrap();
942        wait_for(|| transport.0.permits.available_permits() == 2).await;
943        transport.shutdown().await.unwrap();
944        assert!(!std::fs::read_to_string(log)
945            .unwrap()
946            .contains("must-not-send"));
947    }
948
949    #[test]
950    fn malformed_rpc_envelopes_do_not_become_success() {
951        for value in [
952            json!({"result":{},"error":{}}),
953            json!({}),
954            json!({"error":{"code":"wrong","message":"bad"}}),
955        ] {
956            assert_eq!(
957                decode_response(value).unwrap_err().code,
958                "HOST_PROTOCOL_ERROR"
959            );
960        }
961    }
962}