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