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