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