Skip to main content

cortex/dispatcher/
sink.rs

1use std::error::Error;
2use std::fs::File;
3use std::io;
4use std::io::Write;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
9use std::thread::{self, JoinHandle};
10use std::time::Duration;
11
12use tracing::{info, trace, warn};
13use zeromq::{PullSocket, Socket, SocketRecv, ZmqMessage};
14
15use crate::config::config;
16use crate::dispatcher::server;
17use crate::helpers;
18use crate::helpers::{NewTaskMessage, TaskReport, TaskStatus};
19use crate::models::{Task, WorkerMetadataSender};
20
21/// Capacity of each archive-writer's command channel. A small bound keeps resident memory at
22/// O(chunk) per writer (a handful of `message_size` chunks at most) while letting a writer stay a
23/// little ahead of the receive loop; a full channel simply makes the receive loop wait, which is
24/// the correct backpressure when the disk cannot keep up.
25const SINK_WRITER_CHANNEL_CAPACITY: usize = 64;
26
27/// How often the sink wakes from `recv()` to re-check the shared completion signal, so it can
28/// terminate once the ventilator has finished dispatching and the in-flight set has drained. The
29/// ventilator sets that signal (`dispatch_complete`) both when a **bounded** run hits its limit
30/// (KNOWN_ISSUES D-5) and on a **graceful SIGTERM/SIGINT** (O-1) — so **every** run, production
31/// included, polls on this cadence. A production sink that instead blocked plainly in `recv()` with
32/// an idle fleet never noticed the shutdown and hung the supervisor's stop the full timeout →
33/// SIGKILL on every restart/deploy (KNOWN_ISSUES D-16). `recv()` is cancel-safe — its `FairQueue`
34/// buffers messages behind a `Mutex` and the `recv` future holds no message — so a timed-out poll
35/// loses nothing.
36const SINK_TERMINATION_POLL: Duration = Duration::from_millis(250);
37
38/// A unit of work streamed from the sink's receive loop to an archive-writer thread (dispatcher
39/// rationalization phase 3 — D-7 sink fan-out). The receive loop owns the ZMQ socket and reads each
40/// result's frames; rather than block on the `/data` write itself, it hands a writer the task, then
41/// the received chunks, then a commit/reject — so receiving the next result is no longer hostage to
42/// the current one's disk latency.
43enum WriteCommand {
44  /// Begin a new result file for `task` at `recv_path`.
45  Begin {
46    /// the task this result belongs to (moved to the writer, which reports it on commit)
47    task: Box<Task>,
48    /// `<entry-dir>/<service>.zip` — where the result archive is written
49    recv_path: PathBuf,
50  },
51  /// Append a received data chunk to the in-progress result file. The bytes are owned/moved here
52  /// and dropped right after the write, so the per-job footprint stays O(chunk), never the whole
53  /// archive.
54  Chunk(Vec<u8>),
55  /// Close the in-progress file, parse its `cortex.log` into a report, and hand it to finalize.
56  Commit,
57  /// Reject the in-progress (over-cap) result: remove the partial file and report the task
58  /// `Invalid` (`result_too_large`).
59  Reject {
60    /// the configured cap, for the rejection message
61    max_result_bytes: usize,
62  },
63}
64
65/// One archive-writer thread. It drains [`WriteCommand`]s for the tasks the receive loop assigns
66/// it, performing the blocking `/data` write + `cortex.log` parse + finalize hand-off **off** the
67/// receive loop. Per-task ordering is guaranteed because the receive loop sends a task's
68/// `Begin → Chunk* → Commit|Reject` contiguously to one writer's FIFO channel; fan-out is across
69/// *different* tasks. The writer exits when its channel disconnects (the sink shutting down on
70/// `job_limit`), after draining any buffered commands (so the last result's `Commit` still reaches
71/// finalize — loss-free). A panic here is surfaced by the receive loop (its liveness check / a
72/// send error) → the manager aborts the dispatcher (fail-fast preserved).
73fn run_writer(rx: &Receiver<WriteCommand>, done_tx: &SyncSender<TaskReport>) {
74  // In-progress job state: the task, its result path, and the open file (`None` if create/write
75  // failed — then the result is abandoned and the task is left `Queued` for the reaper, matching
76  // the legacy inline behavior but without the socket desync the old early `continue` caused).
77  let mut current: Option<(Task, PathBuf, Option<File>)> = None;
78  while let Ok(cmd) = rx.recv() {
79    match cmd {
80      WriteCommand::Begin { task, recv_path } => {
81        let file = match File::create(&recv_path) {
82          Ok(f) => Some(f),
83          Err(e) => {
84            warn!(
85              task_id = task.id,
86              path = ?recv_path,
87              error = ?e,
88              "sink writer: File::create failed; task left Queued for the reaper"
89            );
90            None
91          },
92        };
93        current = Some((*task, recv_path, file));
94      },
95      WriteCommand::Chunk(bytes) => {
96        if let Some((task, _, slot)) = current.as_mut()
97          && let Some(file) = slot
98          && let Err(e) = file.write_all(&bytes)
99        {
100          warn!(
101            task_id = task.id,
102            error = ?e,
103            "sink writer: write to result file failed; abandoning result"
104          );
105          // Stop writing; the (now-partial) file makes the result untrustworthy, so Commit
106          // skips the report and the task is recovered by the reaper.
107          *slot = None;
108        }
109        // `bytes` dropped here — tight deallocation, O(chunk) resident.
110      },
111      WriteCommand::Commit => {
112        if let Some((task, recv_path, file)) = current.take() {
113          match file {
114            Some(f) => {
115              drop(f); // flush + close before generate_report reads the archive back
116              let task_id = task.id;
117              match helpers::generate_report(task, &recv_path) {
118                Some(report) => server::send_done(done_tx, report),
119                // Unreadable / empty result archive (0-byte, truncated, no `cortex.log`) — an
120                // infrastructure failure, not a verdict. Skip finalizing it (exactly like the
121                // no-file case below) so the lease reaper recovers the task: retry, then
122                // dead-letter with a message after MAX_DISPATCH_RETRIES — never a
123                // silent terminal Fatal (D-18). Drop the stale 0-byte artifact so a
124                // retry writes a clean file.
125                None => {
126                  std::fs::remove_file(&recv_path).ok();
127                  warn!(
128                    task_id,
129                    "sink writer: empty/unreadable result archive; skipping report so the reaper recovers the task (D-18)"
130                  );
131                },
132              }
133            },
134            None => {
135              warn!(
136                task_id = task.id,
137                "sink writer: no result file (create/write failed); skipping report (reaper will recover)"
138              );
139            },
140          }
141        }
142      },
143      WriteCommand::Reject { max_result_bytes } => {
144        if let Some((task, recv_path, file)) = current.take() {
145          drop(file);
146          std::fs::remove_file(&recv_path).ok();
147          let taskid = task.id;
148          warn!(
149            task_id = taskid,
150            max_result_bytes, "sink: result exceeded byte cap — rejected (Invalid)"
151          );
152          let report = TaskReport {
153            task,
154            status: TaskStatus::Invalid,
155            messages: vec![NewTaskMessage::new(
156              taskid,
157              "invalid",
158              "cortex".to_string(),
159              "result_too_large".to_string(),
160              format!("worker result exceeded the {max_result_bytes}-byte hard cap"),
161            )],
162          };
163          server::send_done(done_tx, report);
164        }
165      },
166    }
167  }
168}
169
170/// The parsed header of a worker reply envelope `[identity, service, taskid, <≥1 data frame>]`.
171/// The data frames (index 3..) are read directly off the [`ZmqMessage`] by the caller.
172struct ReplyHeader {
173  /// the worker's ZMQ identity (for metadata + logs)
174  identity: String,
175  /// the service name the worker claims to have run
176  service: String,
177  /// the task id this result is for (`-1` if the frame wasn't a valid integer)
178  taskid: i64,
179}
180
181/// Validates + parses the reply envelope from one **atomically-received** `zeromq` message. Because
182/// `zeromq` delivers the whole multipart message at once (unlike libzmq's frame-by-frame `recv` +
183/// `RCVMORE`), a short/truncated/malformed reply is simply a message with too few frames — dropping
184/// it cannot desync the *next* reply, so the entire libzmq desync bug class (KNOWN_ISSUES D-4/D-12)
185/// is structurally gone and a frame-count check replaces the four chained `RCVMORE` guards. A valid
186/// reply has **≥4** frames (`identity, service, taskid`, then ≥1 data frame — even an empty result
187/// carries one empty data frame); fewer ⇒ `Err(reason)` for the rate-limited discard log. Pure
188/// (no I/O), so it is unit-tested directly.
189fn parse_reply_envelope(msg: &ZmqMessage) -> Result<ReplyHeader, &'static str> {
190  if msg.len() < 4 {
191    return Err("truncated reply: fewer than 4 frames (no data frame after taskid)");
192  }
193  let frame_str = |i: usize, default: &str| -> String {
194    msg
195      .get(i)
196      .and_then(|f| std::str::from_utf8(f).ok())
197      .unwrap_or(default)
198      .to_string()
199  };
200  Ok(ReplyHeader {
201    identity: frame_str(0, "_worker_"),
202    service: frame_str(1, "_unknown_"),
203    taskid: frame_str(2, "-1").parse::<i64>().unwrap_or(-1),
204  })
205}
206
207/// Specifies the binding and operation parameters for a ZMQ sink component
208pub struct Sink {
209  /// port to listen on
210  pub port: usize,
211  /// the size of the dispatch queue
212  /// (also the batch size for Task store queue requests)
213  pub queue_size: usize,
214  /// size of an individual message chunk sent via zeromq
215  /// (keep this small to avoid large RAM use, increase to reduce network bandwidth)
216  pub message_size: usize,
217  /// address for the Task store postgres endpoint
218  pub backend_address: String,
219  /// non-blocking handle to the background worker-metadata writer
220  pub metadata: WorkerMetadataSender,
221}
222
223impl Sink {
224  /// Starts a receiver/sink `Server` (ZMQ Pull), to accept processing responses.
225  /// The sink shares state with other manager threads via queues for tasks in progress,
226  /// as well as a queue for completed tasks pending persisting to disk.
227  /// Termination is driven entirely by the shared `dispatch_complete` signal (set by the ventilator
228  /// on a bounded run's end **or** a graceful shutdown), not by `_job_limit` — the sink stops once
229  /// dispatching is done and the in-flight set has drained.
230  ///
231  /// **Phase 5a (transport swap):** the receive loop now runs on the pure-Rust async `zeromq`
232  /// transport, driven by a current-thread tokio runtime this sink thread owns. `zeromq` delivers
233  /// each result as **one atomic multipart [`ZmqMessage`]**, which retires the libzmq
234  /// frame-by-frame desync bug class (D-4/D-12): a malformed reply is just a message with too few
235  /// frames, discarded by dropping it — it can no longer swallow the next reply. The blocking
236  /// `/data` write + `cortex.log` parse stay fanned out to the **unchanged** phase-3
237  /// [`run_writer`] std-thread pool (size `dispatcher.sink_writers`); the blocking channel sends
238  /// to it (and to the done channel) from this single-task runtime are the correct backpressure —
239  /// when the disk or DB can't keep up, the loop stops receiving and the workers back off.
240  /// **Note:** `zeromq` exposes no TCP-keepalive knob, so `dispatcher.tcp_keepalive_idle_seconds`
241  /// no longer applies to the sink PULL socket; keepalive was stability-only (remote-worker NAT
242  /// mappings) and the lease reaper remains the correctness net.
243  pub fn start(
244    &self,
245    services_arc: &Arc<server::ServiceCache>,
246    sandboxes_arc: &Arc<server::SandboxCache>,
247    progress_queue_arc: &Arc<server::InFlightSet>,
248    done_tx: &SyncSender<TaskReport>,
249    _job_limit: Option<usize>,
250    dispatch_complete: &Arc<AtomicBool>,
251  ) -> Result<(), Box<dyn Error>> {
252    // Hard cap on a single result's on-disk size (config `dispatcher.max_result_bytes`, default
253    // 2 GiB): a runaway worker must not fill `/data`.
254    let max_result_bytes = config().dispatcher.max_result_bytes;
255
256    // Spin up the archive-writer pool (D-7 fan-out). Each writer owns a bounded command channel and
257    // a clone of the done sender; the receive loop keeps the senders + handles to stream work and
258    // to detect a writer death.
259    let num_writers = config().dispatcher.sink_writers.max(1);
260    let mut writer_txs: Vec<SyncSender<WriteCommand>> = Vec::with_capacity(num_writers);
261    let mut writer_handles: Vec<JoinHandle<()>> = Vec::with_capacity(num_writers);
262    for _ in 0..num_writers {
263      let (tx, rx) = sync_channel::<WriteCommand>(SINK_WRITER_CHANNEL_CAPACITY);
264      let writer_done_tx = done_tx.clone();
265      writer_txs.push(tx);
266      writer_handles.push(thread::spawn(move || run_writer(&rx, &writer_done_tx)));
267    }
268    let mut next_writer = 0_usize;
269
270    let address = format!("tcp://0.0.0.0:{}", self.port);
271
272    // A current-thread runtime is right: there is one PULL socket and one receive loop, and
273    // blocking it on writer/done-channel backpressure is exactly the flow control we want (stop
274    // receiving when the pipeline downstream is full). The writer pool threads drain
275    // independently, so a blocking send here always makes progress.
276    let runtime = tokio::runtime::Builder::new_current_thread()
277      .enable_all()
278      .build()?;
279
280    let recv_result: Result<(), Box<dyn Error>> = runtime.block_on(async {
281      // Retry the bind (mirrors the ventilator) so a port handover from a restarting
282      // dispatcher doesn't crash-loop the sink on EADDRINUSE. The window must OUTLAST TCP
283      // TIME_WAIT (~60s = tcp_fin_timeout), not just a few seconds — see the ventilator's
284      // note (observed 2026-07-01: the old 7.5s window crash-looped the sink through the
285      // full 60s TIME_WAIT until systemd's start-limit gave up).
286      let mut pull = {
287        // 150 * 500ms = 75s > tcp_fin_timeout (60s) TIME_WAIT, with margin.
288        const BIND_ATTEMPTS: u32 = 150;
289        let mut attempt = 1u32;
290        loop {
291          let mut p = PullSocket::new();
292          match p.bind(&address).await {
293            Ok(_) => break p,
294            Err(e) if attempt < BIND_ATTEMPTS => {
295              warn!(
296                address = %address,
297                attempt,
298                max_attempts = BIND_ATTEMPTS,
299                error = %e,
300                "sink: bind failed; retrying in 500ms (port handover from a restarting dispatcher?)"
301              );
302              tokio::time::sleep(Duration::from_millis(500)).await;
303              attempt += 1;
304            },
305            Err(e) => {
306              return Err(
307                io::Error::other(format!(
308                  "sink: zeromq bind {address} failed after {BIND_ATTEMPTS} attempts: {e}"
309                ))
310                .into(),
311              );
312            },
313          }
314        }
315      };
316
317      let mut sink_job_count: usize = 0;
318      // Rate-limited logging for discarded replies (malformed envelopes, unknown task ids). A
319      // sustained bad-reply flood must not turn per-message `stderr` writes into a throughput-DoS
320      // (KNOWN_ISSUES D-11) — count, don't narrate.
321      let mut discard_log = server::RateLimitedLog::new(Duration::from_secs(5));
322
323      loop {
324        // Fail-fast: a writer thread that died (e.g. a panic in `generate_report`) must bring down
325        // the dispatcher, not leave a half-working pipeline. Detect it promptly here (and again via
326        // a send error below) so the manager aborts for a supervised restart (CLAUDE.md fail-fast).
327        if let Some(idx) = writer_handles.iter().position(JoinHandle::is_finished) {
328          return Err(Box::<dyn Error>::from(io::Error::other(format!(
329            "sink writer thread {idx} died unexpectedly; aborting for a supervised restart"
330          ))));
331        }
332
333        // One atomic multipart message: `[identity, service, taskid, ...data]`. No RCVMORE dance —
334        // the whole message arrives at once, so a short/malformed reply is just a short frame
335        // vector (handled by `parse_reply_envelope`), never a desync of the next reply.
336        //
337        // Always poll the recv on a short timeout (every run, production included) so the sink
338        // wakes to re-check the shared completion signal even when idle —
339        // `dispatch_complete` is set by the ventilator both at a bounded run's end (D-5)
340        // and on a graceful SIGTERM/SIGINT (O-1). The timeout only fires while idle
341        // (nothing mid-delivery), and `recv()` is cancel-safe, so a dropped timed-out
342        // future loses no message (KNOWN_ISSUES D-5 + the D-16 idle-shutdown
343        // hang this closes: a production sink no longer blocks past the shutdown signal).
344        let recv_outcome = match tokio::time::timeout(SINK_TERMINATION_POLL, pull.recv()).await {
345          Ok(inner) => inner,
346          Err(_elapsed) => {
347            if dispatch_complete.load(Ordering::Acquire) && progress_queue_arc.is_empty() {
348              info!("sink: dispatching complete and in-flight drained; terminating sink");
349              break;
350            }
351            continue;
352          },
353        };
354        let msg = match recv_outcome {
355          Ok(m) => m,
356          Err(e) => {
357            return Err(Box::<dyn Error>::from(io::Error::other(format!(
358              "sink: zeromq recv failed: {e}"
359            ))));
360          },
361        };
362
363        let header = match parse_reply_envelope(&msg) {
364          Ok(h) => h,
365          Err(reason) => {
366            if let Some(n) = discard_log.record() {
367              warn!(
368                discarded = n,
369                reason = %reason,
370                "sink: discarded malformed reply(ies) (rate-limited)"
371              );
372            }
373            continue;
374          },
375        };
376
377        sink_job_count += 1;
378        let taskid = header.taskid;
379        let request_time = chrono::Utc::now();
380        trace!(
381          job = sink_job_count,
382          service = ?header.service,
383          worker = ?header.identity,
384          task_id = taskid,
385          "sink: incoming result"
386        );
387
388        if let Some(task_progress) = progress_queue_arc.remove(taskid) {
389          let task = task_progress.task;
390          match server::get_service(&header.service, services_arc) {
391            None => {
392              return Err(Box::<dyn Error>::from(io::Error::other(
393                "sink: get_service found nothing",
394              )));
395            },
396            Some(service) => {
397              if service.id == task.service_id {
398                if service.id == 1 {
399                  // No payload needed for init — its (empty) data frames are ignored; report
400                  // inline, no disk write, so no writer involved.
401                  server::send_done(
402                    done_tx,
403                    TaskReport {
404                      task,
405                      status: TaskStatus::NoProblem,
406                      messages: Vec::new(),
407                    },
408                  );
409                } else {
410                  // Derive the result path (cheap, no I/O): `<entry-dir>/<service>.zip`, or a
411                  // sandbox-scoped name when this task's corpus is a sandbox (lock-free cache read,
412                  // memoised by the ventilator on dispatch — F-6).
413                  let sandbox_id = server::get_sandbox_id(task.corpus_id, sandboxes_arc);
414                  let recv_path_opt =
415                    helpers::result_archive_path(&task.entry, &service.name, sandbox_id);
416
417                  // Hard size cap (disk protection): sum the data frames; an over-cap result is
418                  // rejected (Invalid) without being written. The whole multipart message is
419                  // already received (ZMQ delivers it atomically — true of libzmq
420                  // too), so this is the same disk-protection guarantee as the
421                  // streamed check, just computed up front.
422                  let data_bytes: usize = msg.iter().skip(3).map(|f| f.len()).sum();
423                  let oversized = data_bytes > max_result_bytes;
424
425                  let widx = next_writer;
426                  next_writer = (next_writer + 1) % num_writers;
427                  match recv_path_opt {
428                    Some(recv_path) => {
429                      // `Begin` moves the task to the writer; a send error means that writer died →
430                      // fail-fast.
431                      if writer_txs[widx]
432                        .send(WriteCommand::Begin {
433                          task: Box::new(task),
434                          recv_path,
435                        })
436                        .is_err()
437                      {
438                        return Err(Box::<dyn Error>::from(io::Error::other(
439                          "sink writer thread died while beginning a result; aborting",
440                        )));
441                      }
442                      if oversized {
443                        if writer_txs[widx]
444                          .send(WriteCommand::Reject { max_result_bytes })
445                          .is_err()
446                        {
447                          return Err(Box::<dyn Error>::from(io::Error::other(
448                            "sink writer thread died while rejecting a result; aborting",
449                          )));
450                        }
451                      } else {
452                        for frame in msg.iter().skip(3) {
453                          if writer_txs[widx]
454                            .send(WriteCommand::Chunk(frame.to_vec()))
455                            .is_err()
456                          {
457                            return Err(Box::<dyn Error>::from(io::Error::other(
458                              "sink writer thread died while streaming a result; aborting",
459                            )));
460                          }
461                        }
462                        if writer_txs[widx].send(WriteCommand::Commit).is_err() {
463                          return Err(Box::<dyn Error>::from(io::Error::other(
464                            "sink writer thread died while finishing a result; aborting",
465                          )));
466                        }
467                      }
468                    },
469                    None => {
470                      warn!(
471                        task_id = task.id,
472                        entry = ?task.entry,
473                        "sink: could not derive a result path; leaving Queued"
474                      );
475                    },
476                  }
477                }
478                // Update worker metadata (non-blocking enqueue to the background writer).
479                self
480                  .metadata
481                  .received(header.identity.clone(), service.id, taskid);
482              } else if let Some(n) = discard_log.record() {
483                warn!(
484                  discarded = n,
485                  service = ?header.service,
486                  service_id = service.id,
487                  task_service_id = task.service_id,
488                  task_id = taskid,
489                  "sink: discarded reply(ies) [service-id mismatch] (rate-limited)"
490                );
491              }
492            },
493          };
494        } else if let Some(n) = discard_log.record() {
495          // No such in-flight task — drop the message (already fully received; nothing to drain).
496          warn!(
497            discarded = n,
498            task_id = taskid,
499            "sink: discarded reply(ies) for unknown task id(s) (rate-limited)"
500          );
501        }
502
503        let request_duration = (chrono::Utc::now() - request_time).num_milliseconds();
504        let total_incoming: usize = msg.iter().skip(3).map(|f| f.len()).sum();
505        trace!(
506          job = sink_job_count,
507          bytes = total_incoming,
508          recv_ms = request_duration,
509          "sink: received result"
510        );
511
512        // Terminate once the ventilator has signalled dispatching is done AND every dispatched task
513        // has come back (the in-flight set is empty) — the shared completion condition that
514        // replaced the old per-thread `sink_job_count >= limit` (KNOWN_ISSUES D-5), set on
515        // a bounded run's end or a graceful shutdown (D-16). Checked here right after a
516        // result lands (so the run ends promptly when the last one arrives) and on the
517        // recv-timeout poll above (so a sink already blocked when the signal flips still
518        // wakes to notice).
519        if dispatch_complete.load(Ordering::Acquire) && progress_queue_arc.is_empty() {
520          info!("sink: dispatching complete and in-flight drained; terminating sink");
521          break;
522        }
523      }
524      Ok(())
525    });
526
527    // Shut the writer pool down cleanly: dropping the senders disconnects each writer's channel; it
528    // drains any buffered commands first (so the final `Commit` still reaches finalize —
529    // loss-free), then exits. Join them before returning so a `job_limit` run does not race
530    // teardown.
531    drop(writer_txs);
532    for handle in writer_handles {
533      let _ = handle.join();
534    }
535    recv_result
536  }
537}
538
539#[cfg(test)]
540mod tests {
541  use super::parse_reply_envelope;
542  use bytes::Bytes;
543  use zeromq::ZmqMessage;
544
545  /// Build a `ZmqMessage` from frame byte-slices (the test analogue of a received reply).
546  fn message(frames: &[&[u8]]) -> ZmqMessage {
547    let mut iter = frames.iter();
548    let first = iter.next().expect("at least one frame");
549    let mut msg = ZmqMessage::from(first.to_vec());
550    for f in iter {
551      msg.push_back(Bytes::copy_from_slice(f));
552    }
553    msg
554  }
555
556  #[test]
557  fn parses_a_well_formed_reply() {
558    // [identity, service, taskid, data] — the minimal valid reply (one data frame).
559    let msg = message(&[b"worker-7", b"tex_to_html", b"4242", b"<zip bytes>"]);
560    let header = parse_reply_envelope(&msg).expect("valid envelope");
561    assert_eq!(header.identity, "worker-7");
562    assert_eq!(header.service, "tex_to_html");
563    assert_eq!(header.taskid, 4242);
564  }
565
566  #[test]
567  fn rejects_a_reply_with_no_data_frame() {
568    // Exactly 3 frames (no data) is the D-12 shape — must be discarded, not accepted. With atomic
569    // message delivery this can no longer swallow the next reply (it's just a short frame vector).
570    let msg = message(&[b"worker-7", b"tex_to_html", b"4242"]);
571    assert!(parse_reply_envelope(&msg).is_err());
572  }
573
574  #[test]
575  fn rejects_an_empty_or_identity_only_reply() {
576    assert!(parse_reply_envelope(&message(&[b""])).is_err());
577    assert!(parse_reply_envelope(&message(&[b"worker-7"])).is_err());
578    assert!(parse_reply_envelope(&message(&[b"worker-7", b"svc"])).is_err());
579  }
580
581  #[test]
582  fn defaults_a_non_numeric_taskid_to_minus_one() {
583    let msg = message(&[b"w", b"svc", b"not-a-number", b"data"]);
584    let header = parse_reply_envelope(&msg).expect("valid frame count");
585    assert_eq!(
586      header.taskid, -1,
587      "an unpar'seable taskid is -1 (unknown), not a panic"
588    );
589  }
590
591  #[test]
592  fn tolerates_non_utf8_header_frames() {
593    // A hostile/garbled identity must not panic the parse — it falls back to the default label.
594    let msg = message(&[&[0xff, 0xfe], b"svc", b"7", b"data"]);
595    let header = parse_reply_envelope(&msg).expect("valid frame count");
596    assert_eq!(header.identity, "_worker_");
597    assert_eq!(header.taskid, 7);
598  }
599}