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