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#[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 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 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 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 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}