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)]
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 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 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 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 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}