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}