1use crate::{Diagnostic, Failure, invalid, process::ProcessGuard};
2use anyhow::{Context, Result, ensure};
3use serde_json::{Value, json};
4use std::{path::PathBuf, process::Stdio, time::Duration};
5use tokio::{
6 io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader},
7 process::{Child, ChildStdin, ChildStdout, Command},
8};
9use tokio_util::sync::CancellationToken;
10
11#[derive(Clone)]
12pub struct Runtime {
13 pub directory: PathBuf,
14 pub sdk: PathBuf,
15 pub simulate: bool,
16 pub sdk_simulation: Option<Value>,
17}
18impl Runtime {
19 pub async fn usb_command(&self, args: &[&str], seconds: u64) -> Result<String> {
20 ensure!(
21 !self.simulate,
22 "Simulation must not launch USB recovery helpers"
23 );
24 let mut command = Command::new(
25 self.directory
26 .join(format!("regain-device{}", std::env::consts::EXE_SUFFIX)),
27 );
28 command
29 .args(args)
30 .stdin(Stdio::null())
31 .stdout(Stdio::piped())
32 .stderr(Stdio::piped())
33 .kill_on_drop(true);
34 #[cfg(windows)]
35 command.creation_flags(0x08000000);
36 let output = tokio::time::timeout(Duration::from_secs(seconds), command.output())
37 .await
38 .context("USB helper deadline exceeded")??;
39 ensure!(
40 output.status.success(),
41 "USB helper failed ({}; Windows may require administrator approval): {}",
42 output.status,
43 String::from_utf8_lossy(&output.stderr).trim()
44 );
45 Ok(String::from_utf8(output.stdout)?.trim().to_owned())
46 }
47 pub async fn spawn(&self, direct: bool, log: Diagnostic) -> Result<Worker> {
48 let path = self
49 .directory
50 .join(format!("regain-device{}", std::env::consts::EXE_SUFFIX));
51 let mut command = Command::new(&path);
52 command
53 .args([
54 "zwo",
55 if direct {
56 "camera-direct"
57 } else {
58 "camera-sdk"
59 },
60 ])
61 .stdin(Stdio::piped())
62 .stdout(Stdio::piped())
63 .stderr(Stdio::piped())
64 .kill_on_drop(true)
65 .current_dir(&self.directory);
66 #[cfg(windows)]
67 command.creation_flags(0x08000000);
68 if direct {
69 command.arg("--serve");
70 }
71 if self.simulate {
72 command.arg("--simulate");
73 } else if !direct {
74 command.arg("--sdk").arg(&self.sdk);
75 }
76 let mut child = command
77 .spawn()
78 .with_context(|| format!("Start {}", path.display()))?;
79 let pid = child.id().context("Worker has no process ID")?;
80 let guard = ProcessGuard::attach(pid)?;
81 let stderr = child.stderr.take().context("Worker stderr unavailable")?;
82 let diagnostic = tokio::spawn(async move {
83 let mut lines = BufReader::new(stderr).lines();
84 while let Ok(Some(line)) = lines.next_line().await {
85 if let Some(text) = line.strip_prefix("REGAIN_DIAGNOSTIC ")
86 && let Ok(record) = serde_json::from_str::<Value>(text)
87 {
88 log(
89 record["level"].as_str().unwrap_or("info"),
90 record["event"].as_str().unwrap_or("worker"),
91 record["message"].as_str().unwrap_or(&line),
92 );
93 } else {
94 log("info", "worker", &line);
95 }
96 }
97 });
98 let mut worker = Worker {
99 input: child.stdin.take().context("Worker stdin unavailable")?,
100 output: child.stdout.take().context("Worker stdout unavailable")?,
101 child,
102 _guard: guard,
103 diagnostic,
104 id: 0,
105 };
106 if self.simulate
107 && !direct
108 && let Some(settings) = &self.sdk_simulation
109 {
110 worker
111 .call(
112 "simulation",
113 settings.clone(),
114 crate::timing::SIMULATION_SETUP_SECONDS,
115 &CancellationToken::new(),
116 )
117 .await?;
118 }
119 Ok(worker)
120 }
121 pub async fn list(
126 &self,
127 direct: bool,
128 log: Diagnostic,
129 token: &CancellationToken,
130 ) -> Result<Value> {
131 let mut worker = self.spawn(direct, log).await?;
132 let result = worker
133 .call("list", Value::Null, 20., token)
134 .await
135 .map(|r| r.0);
136 worker.kill().await;
137 result
138 }
139}
140pub struct Worker {
141 child: Child,
142 input: ChildStdin,
143 output: ChildStdout,
144 _guard: ProcessGuard,
145 diagnostic: tokio::task::JoinHandle<()>,
146 id: u64,
147}
148impl Worker {
149 pub async fn white_balance(
152 &mut self,
153 settings: Option<crate::white_balance::Settings>,
154 token: &CancellationToken,
155 ) -> Result<Value> {
156 if let Some(settings) = settings {
157 settings.gains.validate()?;
158 }
159 Ok(self
160 .call("white-balance", serde_json::to_value(settings)?, 15., token)
161 .await?
162 .0)
163 }
164 pub fn pid(&self) -> Option<u32> {
165 self.child.id()
166 }
167 pub async fn kill(&mut self) {
168 let _ = self.child.kill().await;
169 let _ = self.child.wait().await;
170 }
171 pub async fn call(
172 &mut self,
173 method: &str,
174 params: Value,
175 seconds: f64,
176 token: &CancellationToken,
177 ) -> Result<(Value, Vec<u8>)> {
178 self.call_bounded(method, params, seconds, token, 512 * 1024 * 1024)
179 .await
180 }
181 pub async fn call_json(
183 &mut self,
184 method: &str,
185 params: Value,
186 seconds: f64,
187 token: &CancellationToken,
188 ) -> Result<Value> {
189 self.call_bounded(method, params, seconds, token, 0)
190 .await
191 .map(|(value, _)| value)
192 }
193 pub async fn call_image(
196 &mut self,
197 method: &str,
198 params: Value,
199 seconds: f64,
200 token: &CancellationToken,
201 maximum_bytes: usize,
202 ) -> Result<(Value, Vec<u8>)> {
203 ensure!(
204 matches!(method, "download" | "stream-download" | "stream-poll")
205 && (1..=512 * 1024 * 1024).contains(&maximum_bytes),
206 Failure::Invalid("Invalid worker image admission".into())
207 );
208 self.call_bounded(method, params, seconds, token, maximum_bytes)
209 .await
210 }
211 async fn call_bounded(
212 &mut self,
213 method: &str,
214 params: Value,
215 seconds: f64,
216 token: &CancellationToken,
217 maximum_bytes: usize,
218 ) -> Result<(Value, Vec<u8>)> {
219 self.call_until(
220 method,
221 params,
222 tokio::time::Instant::now() + Duration::from_secs_f64(seconds),
223 token,
224 maximum_bytes,
225 )
226 .await
227 }
228 pub(crate) async fn control_call(
230 &mut self,
231 method: &str,
232 params: Value,
233 deadline: tokio::time::Instant,
234 token: &CancellationToken,
235 ) -> Result<(Value, Vec<u8>)> {
236 self.call_until(method, params, deadline, token, 0).await
237 }
238 async fn call_until(
239 &mut self,
240 method: &str,
241 params: Value,
242 deadline: tokio::time::Instant,
243 token: &CancellationToken,
244 maximum_bytes: usize,
245 ) -> Result<(Value, Vec<u8>)> {
246 let control =
247 method == "set" && matches!(params["control"].as_i64(), Some(0 | 5 | 16 | 17));
248 let mut dispatched = false;
249 let result = tokio::select! {
250 biased;
251 _=token.cancelled()=>Err(Failure::Cancelled.into()),
252 result=tokio::time::timeout_at(deadline,self.exchange(method,params,maximum_bytes,&mut dispatched,deadline))=>result.unwrap_or_else(|_|{
253 if control && !dispatched {Err(crate::cooling::CoolingError::Expired.into())}
254 else {Err(anyhow::anyhow!("Worker {method} acknowledgement timed out"))}
255 }),
256 };
257 let result = result.map_err(|error| {
258 if control
259 && dispatched
260 && !matches!(
261 error.downcast_ref::<Failure>(),
262 Some(Failure::Worker { .. } | Failure::UncertainControl { .. })
263 )
264 {
265 Failure::UncertainControl {
269 message: format!("{error:#}"),
270 code: None,
271 }
272 .into()
273 } else {
274 error
275 }
276 });
277 if result.is_err()
278 && !(!dispatched
279 && result.as_ref().err().is_some_and(|error| {
280 matches!(
281 error.downcast_ref::<crate::cooling::CoolingError>(),
282 Some(crate::cooling::CoolingError::Expired)
283 )
284 }))
285 && !matches!(
286 result
287 .as_ref()
288 .err()
289 .and_then(|e| e.downcast_ref::<Failure>()),
290 Some(Failure::Worker { .. })
291 )
292 {
293 self.kill().await;
294 }
295 result
296 }
297 async fn exchange(
298 &mut self,
299 method: &str,
300 params: Value,
301 maximum_bytes: usize,
302 dispatched: &mut bool,
303 deadline: tokio::time::Instant,
304 ) -> Result<(Value, Vec<u8>)> {
305 let id = self
306 .id
307 .checked_add(1)
308 .ok_or_else(|| invalid("Worker command counter exhausted"))?;
309 let bytes =
310 serde_json::to_vec(&json!({"version":1,"id":id,"method":method,"params":params}))?;
311 ensure!(
312 bytes.len() <= 65536,
313 Failure::Invalid("Command too large".into())
314 );
315 ensure!(
316 tokio::time::Instant::now() < deadline,
317 crate::cooling::CoolingError::Expired
318 );
319 self.id = id;
320 *dispatched = true;
321 self.input.write_u32_le(bytes.len() as u32).await?;
322 self.input.write_all(&bytes).await?;
323 self.input.flush().await?;
324 read_reply(&mut self.output, method, self.id, maximum_bytes).await
325 }
326}
327
328async fn read_reply<R: AsyncRead + Unpin>(
329 output: &mut R,
330 method: &str,
331 id: u64,
332 maximum_bytes: usize,
333) -> Result<(Value, Vec<u8>)> {
334 let length = output.read_u32_le().await? as usize;
335 ensure!(
336 (1..=65536).contains(&length),
337 Failure::Invalid("Invalid worker response length".into())
338 );
339 let mut header = vec![0; length];
340 output.read_exact(&mut header).await?;
341 let reply: Value =
342 serde_json::from_slice(&header).map_err(|_| invalid("Malformed worker JSON"))?;
343 ensure!(
344 reply["id"] == id && reply["version"] == 1,
345 Failure::Invalid("Stale worker response".into())
346 );
347 let count = reply["binaryLength"]
348 .as_u64()
349 .ok_or_else(|| invalid("Missing frame length"))?;
350 ensure!(
351 count <= maximum_bytes as u64
352 && count <= 512 * 1024 * 1024
353 && (matches!(method, "download" | "stream-download" | "stream-poll") || count == 0),
354 Failure::Invalid("Invalid worker image length".into())
355 );
356 if reply["ok"] == false {
357 ensure!(
358 count == 0,
359 Failure::Invalid("Error response contains pixels".into())
360 );
361 let message = reply["error"].as_str().unwrap_or("Worker error").into();
362 let code = reply["sdkCode"]
363 .as_i64()
364 .and_then(|v| i32::try_from(v).ok());
365 return Err(if reply["controlUncertain"] == true {
366 Failure::UncertainControl { message, code }
367 } else {
368 Failure::Worker { message, code }
369 }
370 .into());
371 }
372 ensure!(
373 reply["ok"] == true,
374 Failure::Invalid("Missing worker status".into())
375 );
376 let mut pixels = Vec::new();
377 pixels
378 .try_reserve_exact(count as usize)
379 .context("Worker image allocation failed")?;
380 pixels.resize(count as usize, 0);
381 output.read_exact(&mut pixels).await?;
382 let mut reply = reply;
384 Ok((reply["result"].take(), pixels))
385}
386impl Drop for Worker {
387 fn drop(&mut self) {
388 self.diagnostic.abort();
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 fn simulated_runtime(settings: Value) -> Runtime {
396 Runtime {
397 directory: std::env::var_os("REGAIN_TEST_WORKERS")
398 .map(PathBuf::from)
399 .unwrap_or_else(|| {
400 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug")
401 }),
402 sdk: "unused".into(),
403 simulate: true,
404 sdk_simulation: Some(settings),
405 }
406 }
407
408 fn response(count: u64, ok: bool) -> Vec<u8> {
409 let header = serde_json::to_vec(&json!({
410 "version":1,"id":7,"ok":ok,"binaryLength":count,
411 "result":{"width":3,"height":2,"readRecoveries":2},
412 "error":"private SDK error","sdkCode":11,
413 }))
414 .unwrap();
415 let mut bytes = (header.len() as u32).to_le_bytes().to_vec();
416 bytes.extend(header);
417 bytes
418 }
419
420 #[tokio::test]
421 async fn json_only_admission_rejects_announced_pixels_before_reading_a_body() {
422 let zero = response(0, true);
423 assert!(
424 read_reply(&mut zero.as_slice(), "list", 7, 0)
425 .await
426 .unwrap()
427 .1
428 .is_empty()
429 );
430 let binary = response(1, true);
433 let error = read_reply(&mut binary.as_slice(), "download", 7, 0)
434 .await
435 .unwrap_err();
436 assert!(error.to_string().contains("Invalid worker image length"));
437 }
438
439 #[tokio::test]
440 async fn timestamped_control_observations_cross_production_worker_framing() {
441 for direct in [false, true] {
442 let runtime = simulated_runtime(json!({"instant":true}));
443 let token = CancellationToken::new();
444 let mut worker = runtime
445 .spawn(direct, std::sync::Arc::new(|_, _, _| {}))
446 .await
447 .unwrap();
448 worker
449 .call(
450 "open",
451 json!({"name":if direct {"ZWO ASI585MM Pro"} else {"ZWO Simulated"}}),
452 15.,
453 &token,
454 )
455 .await
456 .unwrap();
457 worker
458 .call("set", json!({"control":0,"value":123}), 15., &token)
459 .await
460 .unwrap();
461 for control in [0, 8, 15, 16, 17] {
462 let requested = tokio::time::Instant::now();
463 let (reply, pixels) = worker
464 .call("get-observation", json!({"control":control}), 15., &token)
465 .await
466 .unwrap();
467 let observation: crate::ControlObservationReply =
468 serde_json::from_value(reply).unwrap();
469 assert!(pixels.is_empty());
470 assert!(observation.age_seconds.is_finite() && observation.age_seconds >= 0.);
471 assert!(observation.observed_at(requested).unwrap() <= requested);
472 assert_eq!(
473 worker
474 .call("get", json!({"control":control}), 15., &token)
475 .await
476 .unwrap()
477 .0,
478 observation.value
479 );
480 if control == 0 {
481 assert_eq!(observation.value, 123);
482 }
483 }
484 let pid = worker.pid();
485 for control in [json!(999), json!("8"), json!(4294967296u64)] {
486 assert!(
487 worker
488 .call("get-observation", json!({"control":control}), 15., &token)
489 .await
490 .is_err()
491 );
492 }
493 assert_eq!(worker.pid(), pid);
494 worker
495 .call("get-observation", json!({"control":0}), 15., &token)
496 .await
497 .unwrap();
498 worker
499 .call("close", Value::Null, 15., &token)
500 .await
501 .unwrap();
502 worker.kill().await;
503 }
504 }
505
506 #[tokio::test]
507 async fn persistent_control_transport_loss_retires_simulated_worker_without_retry() {
508 let runtime = simulated_runtime(json!({"instant":true,"fault":"hang"}));
509 for control in [0, 5, 16, 17] {
510 for cancel_after_dispatch in [false, true] {
511 let token = CancellationToken::new();
512 let mut worker = runtime
513 .spawn(false, std::sync::Arc::new(|_, _, _| {}))
514 .await
515 .unwrap();
516 worker
517 .call("open", json!({"name":"ZWO Simulated"}), 15., &token)
518 .await
519 .unwrap();
520 worker.call("start",json!({"width":64,"height":64,"bin":1,"x":0,"y":0,"microseconds":1000,"dark":false}),15.,&token).await.unwrap();
521 worker.id += 1;
524 let header = serde_json::to_vec(
525 &json!({"version":1,"id":worker.id,"method":"download","params":null}),
526 )
527 .unwrap();
528 worker
529 .input
530 .write_u32_le(header.len() as u32)
531 .await
532 .unwrap();
533 worker.input.write_all(&header).await.unwrap();
534 worker.input.flush().await.unwrap();
535 let mut call =
536 Box::pin(worker.call("set", json!({"control":control,"value":0}), 1., &token));
537 if cancel_after_dispatch {
538 std::future::poll_fn(|cx| {
541 assert!(std::future::Future::poll(call.as_mut(), cx).is_pending());
542 std::task::Poll::Ready(())
543 })
544 .await;
545 token.cancel();
546 }
547 let error = call.await.unwrap_err();
548 assert!(
549 matches!(
550 error.downcast_ref::<Failure>(),
551 Some(Failure::UncertainControl { .. })
552 ),
553 "{error:#}"
554 );
555 assert!(!crate::retryable(&error));
556 assert!(worker.child.try_wait().unwrap().is_some());
557 }
558 }
559 let mut worker = runtime
562 .spawn(false, std::sync::Arc::new(|_, _, _| {}))
563 .await
564 .unwrap();
565 let id = worker.id;
566 let token = CancellationToken::new();
567 token.cancel();
568 let error = worker
569 .call("set", json!({"control":17,"value":0}), 1., &token)
570 .await
571 .unwrap_err();
572 assert!(matches!(
573 error.downcast_ref::<Failure>(),
574 Some(Failure::Cancelled)
575 ));
576 assert_eq!(worker.id, id);
577 assert!(worker.child.try_wait().unwrap().is_some());
578 }
579
580 #[tokio::test]
581 async fn expired_control_write_preserves_worker_and_framing_without_dispatch() {
582 let mut worker = simulated_runtime(json!({"instant":true}))
583 .spawn(false, std::sync::Arc::new(|_, _, _| {}))
584 .await
585 .unwrap();
586 let token = CancellationToken::new();
587 let id = worker.id;
588 for control in [0, 5, 16, 17] {
589 let error = worker
590 .control_call(
591 "set",
592 json!({"control":control,"value":0}),
593 tokio::time::Instant::now(),
594 &token,
595 )
596 .await
597 .unwrap_err();
598 assert!(matches!(
599 error.downcast_ref::<crate::cooling::CoolingError>(),
600 Some(crate::cooling::CoolingError::Expired)
601 ));
602 assert_eq!(worker.id, id);
603 assert!(worker.child.try_wait().unwrap().is_none());
604 }
605 worker
606 .call("open", json!({"name":"ZWO Simulated"}), 15., &token)
607 .await
608 .unwrap();
609 worker
610 .call("close", Value::Null, 15., &token)
611 .await
612 .unwrap();
613 worker.kill().await;
614 }
615
616 #[tokio::test]
617 async fn uncertain_control_reply_is_typed_and_never_retryable() {
618 for code in [Some(11), None] {
619 let header=serde_json::to_vec(&json!({"version":1,"id":7,"ok":false,
620 "binaryLength":0,"error":"private cooling failure","sdkCode":code,"controlUncertain":true})).unwrap();
621 let mut bytes = (header.len() as u32).to_le_bytes().to_vec();
622 bytes.extend(header);
623 let error = read_reply(&mut bytes.as_slice(), "set", 7, 0)
624 .await
625 .unwrap_err();
626 assert!(
627 matches!(error.downcast_ref::<Failure>(),Some(Failure::UncertainControl {code:actual,message}) if *actual==code && message=="private cooling failure")
628 );
629 assert!(!crate::retryable(&error));
630 }
631 }
632
633 #[tokio::test]
634 async fn oversized_admitted_reply_is_rejected_before_reading_any_body() {
635 let (mut writer, mut reader) = tokio::io::duplex(1024);
636 writer.write_all(&response(13, true)).await.unwrap();
637 let error = tokio::time::timeout(
639 Duration::from_secs(1),
640 read_reply(&mut reader, "download", 7, 12),
641 )
642 .await
643 .unwrap()
644 .unwrap_err();
645 assert!(
646 matches!(error.downcast_ref::<Failure>(), Some(Failure::Invalid(message)) if message == "Invalid worker image length")
647 );
648 assert!(!crate::retryable(&error));
649 }
650
651 #[tokio::test]
652 async fn bounded_reply_preserves_pixels_metadata_and_empty_stream_poll() {
653 let mut bytes = response(12, true);
654 bytes.extend(0u8..12);
655 let (metadata, pixels) = read_reply(&mut bytes.as_slice(), "download", 7, 12)
656 .await
657 .unwrap();
658 assert_eq!(pixels, (0u8..12).collect::<Vec<_>>());
659 assert_eq!(metadata["readRecoveries"], 2);
660 let bytes = response(0, true);
661 assert!(
662 read_reply(&mut bytes.as_slice(), "stream-poll", 7, 12)
663 .await
664 .unwrap()
665 .1
666 .is_empty()
667 );
668 }
669
670 #[tokio::test]
671 async fn bounded_reply_keeps_error_codes_and_rejects_stale_or_non_image_pixels() {
672 let bytes = response(0, false);
673 let error = read_reply(&mut bytes.as_slice(), "download", 7, 12)
674 .await
675 .unwrap_err();
676 assert!(
677 matches!(error.downcast_ref::<Failure>(), Some(Failure::Worker {code:Some(11), message}) if message == "private SDK error")
678 );
679 for (count, ok, method, id) in [
680 (1, false, "download", 7),
681 (1, true, "status", 7),
682 (0, true, "download", 8),
683 ] {
684 let bytes = response(count, ok);
685 let error = read_reply(&mut bytes.as_slice(), method, id, 12)
686 .await
687 .unwrap_err();
688 assert!(matches!(
689 error.downcast_ref::<Failure>(),
690 Some(Failure::Invalid(_))
691 ));
692 }
693 let bytes = response(12, true);
694 let error = read_reply(&mut bytes.as_slice(), "download", 7, 12)
695 .await
696 .unwrap_err();
697 assert_eq!(
698 error.downcast_ref::<std::io::Error>().unwrap().kind(),
699 std::io::ErrorKind::UnexpectedEof
700 );
701 }
702}