Skip to main content

regain_core/
worker.rs

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/// A worker diagnostic can be forwarded intact through the existing callback.
12/// Unrecognized text stays ordinary diagnostic prose.
13pub 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    /// Configure diagnostics before the SDK is loaded in the isolated worker.
68    /// An explicit environment off switch remains a host-wide override.
69    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                        // Keep the complete worker record. Transfer IDs,
123                        // timestamps, results and register evidence must reach
124                        // the frontend, not just its human-readable message.
125                        &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    /// SDK discovery is active hardware probing, even in a short-lived worker:
156    /// ASIGetCameraProperty can internally open cameras owned by other processes.
157    /// Cache results and schedule SDK discovery before imaging, not as live polling.
158    /// Worker isolation bounds user-space calls, not side effects on shared USB.
159    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    /// Query (None) or configure shared software white balance while idle.
184    /// Inspect the open response's `whiteBalance.supported` capability first.
185    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        // Consume the closed stderr pipe before dropping its reader. In
205        // particular, retain the last transfer/cleanup evidence after a fault.
206        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    /// JSON-only commands reject any announced binary body before allocating it.
225    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    /// Bound an image reply by the caller's admitted ROI before allocating pixels.
237    /// A zero-length streaming poll remains valid; capture checks exact length.
238    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    /// Persistent camera controls share absolute-deadline/framed-write tracking.
272    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                // The process may have consumed a partial/complete command. A
309                // timeout, cancellation or malformed/lost reply cannot prove
310                // this persistent hardware write was not applied.
311                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    // Move the result out, avoiding a second decoded metadata tree.
426    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        // No body follows this header. Reject its announced size rather than
497        // attempting an allocation or reporting a later partial-body failure.
498        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                // Deliberately park the simulated owner in download. Its serial
600                // command queue then cannot acknowledge the following control write.
601                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                    // Poll through write admission before cancellation, rather than
616                    // using a scheduling-sensitive sleep to guess dispatch timing.
617                    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        // Before-dispatch cancellation is still distinguishable from a write
637        // whose acknowledgement was lost. No command ID is consumed.
638        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        // Writer stays open and sends no body: waiting for pixels would time out.
715        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}