Skip to main content

cortex/
helpers.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Helper structures and methods for Task
9use rand::RngExt;
10use regex::Regex;
11use std::fs::File;
12use std::io;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::str;
16use std::sync::LazyLock;
17
18use diesel::pg::PgConnection;
19use diesel::result::Error;
20
21use crate::concerns::CortexInsertable;
22use crate::models::{
23  LogError, LogFatal, LogInfo, LogInvalid, LogRecord, LogWarning, NewLogError, NewLogFatal,
24  NewLogInfo, NewLogInvalid, NewLogWarning, Task,
25};
26
27static MESSAGE_LINE_REGEX: LazyLock<Regex> =
28  LazyLock::new(|| Regex::new(r"^([^ :]+):([^ :]+):([^ ]+)(\s(.*))?$").unwrap());
29/// "(Loading... file" message regex
30pub static LOADING_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
31  Regex::new(r"^\((?:Loading|Processing definitions)\s(.+/)?([^/]+[^.])\.\.\.(\s|$)").unwrap()
32});
33/// The short document name within an entry path: everything between the last `/` and the last `.`.
34static ENTRY_DOCUMENT_NAME_REGEX: LazyLock<Regex> =
35  LazyLock::new(|| Regex::new(r"^.+/(.+)\..+$").unwrap());
36
37/// The short document name shown in reports and used for download filenames — an entry path's
38/// basename without its directory or extension (e.g. `/data/…/0811.0417/0811.0417.zip` →
39/// `0811.0417`). Falls back to the trimmed entry when the path doesn't match the `…/name.ext`
40/// shape.
41pub fn entry_document_name(entry: &str) -> String {
42  let trimmed = entry.trim_end();
43  ENTRY_DOCUMENT_NAME_REGEX.replace(trimmed, "$1").to_string()
44}
45
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47/// An enumeration of the expected task statuses. A small value type (every variant is a unit or a
48/// single `i32`) that round-trips through `i32` via [`TaskStatus::raw`]/[`TaskStatus::from_raw`],
49/// so it is `Copy`.
50pub enum TaskStatus {
51  /// currently queued for processing
52  TODO,
53  /// everything went smoothly
54  NoProblem,
55  /// minor issues
56  Warning,
57  /// major issues
58  Error,
59  /// critical/panic issues
60  Fatal,
61  /// invalid task, fatal + discard from statistics
62  Invalid,
63  /// currently blocked by dependencies
64  Blocked(i32),
65  /// currently being processed (marker identifies batch)
66  Queued(i32),
67}
68
69#[derive(Clone, Debug)]
70/// In-progress task, with dispatch metadata
71pub struct TaskProgress {
72  /// the `Task` struct being tracked
73  pub task: Task,
74  /// when the current attempt was **dispatched** to a worker (unix seconds) — stamped by the
75  /// ventilator at every lease, so the lease clock starts when a worker has the task. (It was
76  /// stamped when the task was *fetched* into the dispatch queue, which with an 800-task queue
77  /// started the clock minutes before dispatch: tasks were leased already past their deadline,
78  /// reaped while converting, and their results discarded — KNOWN_ISSUES D-27.)
79  pub created_at: i64,
80  /// number of dispatch retries
81  pub retries: i64,
82  /// the effective lease / visibility timeout (seconds) for THIS task, captured at dispatch time
83  /// from the owning service's `lease_timeout_seconds` override or the global dispatcher default
84  /// (D-17). Stored per-task so a config change never re-times an already-leased task, and so one
85  /// dispatcher can serve fast (latexml-oxide) and slow (Perl) services with different leases.
86  pub lease_timeout_seconds: i64,
87  /// the worker identity each dispatch attempt went to, in lease order: entry `n` is the attempt
88  /// made at `retries == n`. The sink attributes a result to its attempt by the replying worker's
89  /// identity, so a late result of an older attempt never overwrites a newer attempt's finished
90  /// result (KNOWN_ISSUES D-26).
91  pub attempt_workers: Vec<String>,
92  /// the messages of the last **retryable** cause reported for the task (empty: none) — e.g. the
93  /// worker harness's `Fatal:timeout:wallclock` for a child the watchdog ended. Carried across
94  /// retries; if the retries run out, the dead-letter reports it instead of
95  /// `never_completed_with_retries`.
96  pub last_cause: Vec<NewTaskMessage>,
97  /// the index (in [`Self::attempt_workers`]) of the first attempt of the task's current **run**:
98  /// `0`, unless the dispatcher still held the task when it was fetched afresh (an operator's
99  /// rerun), when the earlier run's attempts stay on record before it — and their late results
100  /// never land in the new run (KNOWN_ISSUES D-28)
101  pub run_start: usize,
102}
103impl TaskProgress {
104  /// What is the latest admissible time for this attempt to be completed? The base deadline is
105  /// this task's captured lease / visibility timeout (`lease_timeout_seconds`) from the attempt's
106  /// dispatch; each retry extends it by another full timeout (`(retries + 1) × timeout`), so a
107  /// task that keeps timing out backs off rather than re-leasing ever-faster.
108  pub fn expected_at(&self) -> i64 {
109    self.created_at + ((self.retries + 1) * self.lease_timeout_seconds)
110  }
111}
112
113/// The `what` of the dispatcher's dead-letter: the `Fatal` (category `cortex`) the reaper reports
114/// for a task none of whose dispatch attempts returned a result in time.
115pub const NEVER_COMPLETED_WHAT: &str = "never_completed_with_retries";
116
117#[derive(Clone, Debug)]
118/// Completed task, with its processing status and report messages
119pub struct TaskReport {
120  /// the `Task` we are reporting on
121  pub task: Task,
122  /// the reported processing status
123  pub status: TaskStatus,
124  /// a vector of `TaskMessage` log entries
125  pub messages: Vec<NewTaskMessage>,
126  /// a **stand-in** the dispatcher reports for a task with no result — the reaper's dead-letter,
127  /// or an archive it could not publish — rather than a worker's result: any attempt's result
128  /// replaces it, and it never replaces one (KNOWN_ISSUES D-26)
129  pub stand_in: bool,
130}
131
132/// The category/what of the tag a worker puts in its `cortex.log` (`Info:cortex:retryable …`) to
133/// mark a reported cause as retryable rather than a result: the worker harness tags a child's
134/// watchdog timeout, governor shed and unrecorded death.
135pub const RETRYABLE_TAG: (&str, &str) = ("cortex", "retryable");
136
137impl TaskReport {
138  /// Whether this report is a **retryable cause** (tagged [`RETRYABLE_TAG`]) rather than a result:
139  /// the dispatcher returns the task to its retry budget
140  /// ([`crate::dispatcher::server::InFlightSet::defer_retryable`]). Strict, so a document's own
141  /// `\message` cannot fake it: the status is `Fatal`, and the tag is directly followed by the
142  /// report's last `Fatal` message, whose `<category>:<what>` is the tag's text
143  /// (`Info:cortex:retryable timeout:wallclock` then `Fatal:timeout:wallclock …`).
144  pub fn is_retryable(&self) -> bool {
145    if self.status != TaskStatus::Fatal {
146      return false;
147    }
148    let Some(last_fatal) = self
149      .messages
150      .iter()
151      .rposition(|message| matches!(message, NewTaskMessage::Fatal(_)))
152    else {
153      return false;
154    };
155    let (Some(NewTaskMessage::Info(tag)), NewTaskMessage::Fatal(fatal)) = (
156      last_fatal.checked_sub(1).and_then(|i| self.messages.get(i)),
157      &self.messages[last_fatal],
158    ) else {
159      return false;
160    };
161    (tag.category.as_str(), tag.what.as_str()) == RETRYABLE_TAG
162      && tag.details.trim() == format!("{}:{}", fatal.category, fatal.what)
163  }
164
165  /// The messages of a retryable cause, without its tag: what the dead-letter reports if the
166  /// retries run out.
167  pub fn into_cause(self) -> Vec<NewTaskMessage> {
168    self
169      .messages
170      .into_iter()
171      .filter(|message| {
172        !matches!(message, NewTaskMessage::Info(info)
173          if (info.category.as_str(), info.what.as_str()) == RETRYABLE_TAG)
174      })
175      .collect()
176  }
177}
178
179#[derive(Clone, Debug)]
180/// Enum for all types of reported messages for a given Task, as per the `LaTeXML` convention
181/// One of "invalid", "fatal", "error", "warning" or "info"
182pub enum TaskMessage {
183  /// Debug/low-priroity messages
184  Info(LogInfo),
185  /// Soft/resumable problem messages
186  Warning(LogWarning),
187  /// Hard/recoverable problem messages
188  Error(LogError),
189  /// Critical/unrecoverable problem messages
190  Fatal(LogFatal),
191  /// Invalid tasks, work can not begin
192  Invalid(LogInvalid),
193}
194impl LogRecord for TaskMessage {
195  fn task_id(&self) -> i64 {
196    use crate::helpers::TaskMessage::*;
197    match *self {
198      Info(ref record) => record.task_id(),
199      Warning(ref record) => record.task_id(),
200      Error(ref record) => record.task_id(),
201      Fatal(ref record) => record.task_id(),
202      Invalid(ref record) => record.task_id(),
203    }
204  }
205  fn category(&self) -> &str {
206    use crate::helpers::TaskMessage::*;
207    match *self {
208      Info(ref record) => record.category(),
209      Warning(ref record) => record.category(),
210      Error(ref record) => record.category(),
211      Fatal(ref record) => record.category(),
212      Invalid(ref record) => record.category(),
213    }
214  }
215  fn what(&self) -> &str {
216    use crate::helpers::TaskMessage::*;
217    match *self {
218      Info(ref record) => record.what(),
219      Warning(ref record) => record.what(),
220      Error(ref record) => record.what(),
221      Fatal(ref record) => record.what(),
222      Invalid(ref record) => record.what(),
223    }
224  }
225  fn details(&self) -> &str {
226    use crate::helpers::TaskMessage::*;
227    match *self {
228      Info(ref record) => record.details(),
229      Warning(ref record) => record.details(),
230      Error(ref record) => record.details(),
231      Fatal(ref record) => record.details(),
232      Invalid(ref record) => record.details(),
233    }
234  }
235  fn set_details(&mut self, new_details: String) {
236    use crate::helpers::TaskMessage::*;
237    match *self {
238      Info(ref mut record) => record.set_details(new_details),
239      Warning(ref mut record) => record.set_details(new_details),
240      Error(ref mut record) => record.set_details(new_details),
241      Fatal(ref mut record) => record.set_details(new_details),
242      Invalid(ref mut record) => record.set_details(new_details),
243    }
244  }
245  fn severity(&self) -> &str {
246    use crate::helpers::TaskMessage::*;
247    match *self {
248      Info(ref record) => record.severity(),
249      Warning(ref record) => record.severity(),
250      Error(ref record) => record.severity(),
251      Fatal(ref record) => record.severity(),
252      Invalid(ref record) => record.severity(),
253    }
254  }
255}
256
257impl TaskStatus {
258  /// Maps the enumeration into the raw ints for the Task store
259  pub fn raw(&self) -> i32 {
260    match *self {
261      TaskStatus::TODO => 0,
262      TaskStatus::NoProblem => -1,
263      TaskStatus::Warning => -2,
264      TaskStatus::Error => -3,
265      TaskStatus::Fatal => -4,
266      TaskStatus::Invalid => -5,
267      TaskStatus::Blocked(x) | TaskStatus::Queued(x) => x,
268    }
269  }
270  /// Maps the enumeration into the raw severity string for the Task store logs / frontend reports
271  pub fn to_key(&self) -> String {
272    match *self {
273      TaskStatus::NoProblem => "no_problem",
274      TaskStatus::Warning => "warning",
275      TaskStatus::Error => "error",
276      TaskStatus::Fatal => "fatal",
277      TaskStatus::TODO => "todo",
278      TaskStatus::Invalid => "invalid",
279      TaskStatus::Blocked(_) => "blocked",
280      TaskStatus::Queued(_) => "queued",
281    }
282    .to_string()
283  }
284  /// Maps the enumeration into the Postgresql table name expected to hold messages for this
285  /// status
286  pub fn to_table(&self) -> String {
287    match *self {
288      TaskStatus::Warning => "log_warnings",
289      TaskStatus::Error => "log_errors",
290      TaskStatus::Fatal => "log_fatals",
291      TaskStatus::Invalid => "log_invalids",
292      _ => "log_infos",
293    }
294    .to_string()
295  }
296  /// Maps from the raw Task store value into the enumeration
297  pub fn from_raw(num: i32) -> Self {
298    match num {
299      0 => TaskStatus::TODO,
300      -1 => TaskStatus::NoProblem,
301      -2 => TaskStatus::Warning,
302      -3 => TaskStatus::Error,
303      -4 => TaskStatus::Fatal,
304      -5 => TaskStatus::Invalid,
305      num if num < -5 => TaskStatus::Blocked(num),
306      _ => TaskStatus::Queued(num),
307    }
308  }
309  /// Maps from the raw severity log values into the enumeration
310  pub fn from_key(key: &str) -> Option<Self> {
311    match key.to_lowercase().as_str() {
312      "no_problem" => Some(TaskStatus::NoProblem),
313      "warning" => Some(TaskStatus::Warning),
314      "error" => Some(TaskStatus::Error),
315      "todo" => Some(TaskStatus::TODO),
316      "in_progress" => Some(TaskStatus::TODO),
317      "invalid" => Some(TaskStatus::Invalid),
318      "blocked" => Some(TaskStatus::Blocked(-6)),
319      "queued" => Some(TaskStatus::Queued(1)),
320      "fatal" => Some(TaskStatus::Fatal),
321      _ => None,
322    }
323  }
324  /// Returns all raw severity strings as a vector
325  pub fn keys() -> Vec<String> {
326    [
327      "no_problem",
328      "warning",
329      "error",
330      "fatal",
331      "invalid",
332      "todo",
333      "blocked",
334      "queued",
335    ]
336    .iter()
337    .map(|&x| x.to_string())
338    .collect::<Vec<_>>()
339  }
340}
341
342#[derive(Clone, Debug)]
343/// Enum for all types of reported messages for a given Task, as per the `LaTeXML` convention
344/// One of "invalid", "fatal", "error", "warning" or "info"
345pub enum NewTaskMessage {
346  /// Debug/low-priroity messages
347  Info(NewLogInfo),
348  /// Soft/resumable problem messages
349  Warning(NewLogWarning),
350  /// Hard/recoverable problem messages
351  Error(NewLogError),
352  /// Critical/unrecoverable problem messages
353  Fatal(NewLogFatal),
354  /// Invalid tasks, work can not begin
355  Invalid(NewLogInvalid),
356}
357impl LogRecord for NewTaskMessage {
358  fn task_id(&self) -> i64 {
359    use crate::helpers::NewTaskMessage::*;
360    match *self {
361      Info(ref record) => record.task_id(),
362      Warning(ref record) => record.task_id(),
363      Error(ref record) => record.task_id(),
364      Fatal(ref record) => record.task_id(),
365      Invalid(ref record) => record.task_id(),
366    }
367  }
368  fn category(&self) -> &str {
369    use crate::helpers::NewTaskMessage::*;
370    match *self {
371      Info(ref record) => record.category(),
372      Warning(ref record) => record.category(),
373      Error(ref record) => record.category(),
374      Fatal(ref record) => record.category(),
375      Invalid(ref record) => record.category(),
376    }
377  }
378  fn what(&self) -> &str {
379    use crate::helpers::NewTaskMessage::*;
380    match *self {
381      Info(ref record) => record.what(),
382      Warning(ref record) => record.what(),
383      Error(ref record) => record.what(),
384      Fatal(ref record) => record.what(),
385      Invalid(ref record) => record.what(),
386    }
387  }
388  fn details(&self) -> &str {
389    use crate::helpers::NewTaskMessage::*;
390    match *self {
391      Info(ref record) => record.details(),
392      Warning(ref record) => record.details(),
393      Error(ref record) => record.details(),
394      Fatal(ref record) => record.details(),
395      Invalid(ref record) => record.details(),
396    }
397  }
398  fn set_details(&mut self, new_details: String) {
399    use crate::helpers::NewTaskMessage::*;
400    match *self {
401      Info(ref mut record) => record.set_details(new_details),
402      Warning(ref mut record) => record.set_details(new_details),
403      Error(ref mut record) => record.set_details(new_details),
404      Fatal(ref mut record) => record.set_details(new_details),
405      Invalid(ref mut record) => record.set_details(new_details),
406    }
407  }
408
409  fn severity(&self) -> &str {
410    use crate::helpers::NewTaskMessage::*;
411    match *self {
412      Info(ref record) => record.severity(),
413      Warning(ref record) => record.severity(),
414      Error(ref record) => record.severity(),
415      Fatal(ref record) => record.severity(),
416      Invalid(ref record) => record.severity(),
417    }
418  }
419}
420impl CortexInsertable for NewTaskMessage {
421  fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
422    use crate::helpers::NewTaskMessage::*;
423    match *self {
424      Info(ref record) => record.create(connection),
425      Warning(ref record) => record.create(connection),
426      Error(ref record) => record.create(connection),
427      Fatal(ref record) => record.create(connection),
428      Invalid(ref record) => record.create(connection),
429    }
430  }
431}
432
433impl NewTaskMessage {
434  /// Instantiates an appropriate insertable LogRecord object based on the raw message components
435  pub fn new(
436    task_id: i64,
437    severity: &str,
438    category: String,
439    what: String,
440    details: String,
441  ) -> NewTaskMessage {
442    match severity.to_lowercase().as_str() {
443      // Canonical Perl-LaTeXML token is "Warning"; tolerate the abbreviated "Warn" too so a
444      // producer that emits `Warn:` doesn't silently land in the `_ => Info` default (which once
445      // misfiled every latexml-oxide warning as Info).
446      "warning" | "warn" => NewTaskMessage::Warning(NewLogWarning {
447        task_id,
448        category,
449        what,
450        details,
451      }),
452      "error" => NewTaskMessage::Error(NewLogError {
453        task_id,
454        category,
455        what,
456        details,
457      }),
458      // Perl-LaTeXML emits `Fatal('invalid', …)` for an *unprocessable input* (no TeX source,
459      // PDF-only, binary, …): a fatal whose CATEGORY is `invalid`. CorTeX treats that as its
460      // distinct **Invalid** outcome — its own report row, discounted from the total — not a plain
461      // conversion Fatal. This is the bridge that realizes the long-noted intent in
462      // `generate_report` ("they're fatal messages in latexml, but we want them separated out in
463      // cortex"); the literal `invalid:` severity below resolves to the same record.
464      "fatal" if category == "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
465        task_id,
466        category,
467        what,
468        details,
469      }),
470      "fatal" => NewTaskMessage::Fatal(NewLogFatal {
471        category,
472        task_id,
473        what,
474        details,
475      }),
476      "invalid" => NewTaskMessage::Invalid(NewLogInvalid {
477        task_id,
478        category,
479        what,
480        details,
481      }),
482      _ => NewTaskMessage::Info(NewLogInfo {
483        task_id,
484        category,
485        what,
486        details,
487      }), // unknown severity will be treated as info
488    }
489  }
490}
491
492/// Parses a log string which follows the `LaTeXML` convention
493/// (described at [the Manual](http://dlmf.nist.gov/LaTeXML/manual/errorcodes/index.html))
494pub fn parse_log(task_id: i64, log: &str) -> Vec<NewTaskMessage> {
495  let mut messages: Vec<NewTaskMessage> = Vec::new();
496  let mut in_details_mode = false;
497
498  for line in log.lines() {
499    // Skip empty lines
500    if line.is_empty() {
501      continue;
502    }
503    // If we have found a message header and we're collecting details:
504    if in_details_mode {
505      // If the line starts with tab, we are indeed reading in details
506      if line.starts_with('\t') {
507        // Append the details line to the last message. `in_details_mode` is only ever set true
508        // right after a message is pushed, so `messages` is non-empty here by invariant — but we
509        // never `.expect()`-panic the dispatch path on a hostile log (DESIGN_PRINCIPLES): if that
510        // invariant is ever broken, treat the orphan details line as noise and carry on rather than
511        // aborting the whole task's log parse.
512        if let Some(mut last_message) = messages.pop() {
513          let mut truncated_details = last_message.details().to_string() + "\n" + line;
514          utf_truncate(&mut truncated_details, 2000);
515          last_message.set_details(truncated_details);
516          messages.push(last_message);
517        }
518        continue; // This line has been consumed, next
519      } else {
520        // Otherwise, no tab at the line beginning means last message has ended
521        in_details_mode = false;
522        if in_details_mode {} // hacky? disable "unused" warning
523      }
524    }
525    // Since this isn't a details line, check if it's a message line:
526    if let Some(cap) = MESSAGE_LINE_REGEX.captures(line) {
527      // Indeed a message, so record it
528      // We'll need to do some manual truncations, since the POSTGRESQL wrapper prefers
529      //   panicking to auto-truncating (would not have been the Perl way, but Rust is Rust)
530      let mut truncated_severity = cap
531        .get(1)
532        .map_or("", |m| m.as_str())
533        .to_string()
534        .to_lowercase();
535      utf_truncate(&mut truncated_severity, 50);
536      let mut truncated_category = cap.get(2).map_or("", |m| m.as_str()).to_string();
537      utf_truncate(&mut truncated_category, 100);
538      let mut truncated_what = cap.get(3).map_or("", |m| m.as_str()).to_string();
539      // `what` is varchar(200) (widened from 50 so math-parser footprints — `ambiguous_math` /
540      // `unparsed_math` token-type signatures — fit as the groupable key; the full stream stays in
541      // `details`). `category` stays varchar(50).
542      utf_truncate(&mut truncated_what, 200);
543      let mut truncated_details = cap.get(5).map_or("", |m| m.as_str()).to_string();
544      utf_truncate(&mut truncated_details, 2000);
545
546      if truncated_severity == "fatal" && truncated_category == "invalid" {
547        truncated_severity = "invalid".to_string();
548        truncated_category = truncated_what;
549        truncated_what = "all".to_string();
550        // The swap just moved a (now up-to-200-char) `what` into `category`, which is
551        // varchar(100) — re-clamp so the insert can't overflow it.
552        utf_truncate(&mut truncated_category, 100);
553      }
554
555      let message = NewTaskMessage::new(
556        task_id,
557        &truncated_severity,
558        truncated_category,
559        truncated_what,
560        truncated_details,
561      );
562      // Prepare to record follow-up lines with the message details:
563      in_details_mode = true;
564      // Add to the array of parsed messages
565      messages.push(message);
566    } else {
567      in_details_mode = false; // not a details line.
568      if let Some(cap) = LOADING_LINE_REGEX.captures(line) {
569        // Special case is a "Loading..." info messages
570        let mut filepath = cap.get(1).map_or("", |m| m.as_str()).to_string();
571        let mut filename = cap.get(2).map_or("", |m| m.as_str()).to_string();
572        // `what` (the filename) is a groupable key, capped at 100 — its column is
573        // varchar(200). The full path goes to `details` (varchar(2000)); the old 50
574        // cap cut real texmf paths mid-directory — for tcolorbox.sty the directory
575        // alone is exactly 50 chars, so the filename was dropped outright, leaving
576        // an unusable source path.
577        utf_truncate(&mut filename, 100);
578        filepath += &filename;
579        utf_truncate(&mut filepath, 2000);
580        messages.push(NewTaskMessage::new(
581          task_id,
582          "info",
583          "loaded_file".to_string(),
584          filename,
585          filepath,
586        ));
587      } else {
588        // Otherwise line is just noise, continue...
589      }
590    }
591  }
592  messages
593}
594
595/// Decodes raw worker-log bytes into a string, **tolerating non-UTF-8 input** (arXiv data is
596/// hostile and workers are unpredictable — W-2). A single invalid byte used to discard the whole
597/// log and force-mark the task `Fatal`, throwing away every real conversion message + the true
598/// status. Instead we decode lossily (invalid sequences → U+FFFD), preserving the real log, and
599/// append a `Warning` line so the encoding issue is recorded *transparently* rather than silently
600/// swallowed.
601fn decode_worker_log(raw: &[u8]) -> String {
602  match str::from_utf8(raw) {
603    Ok(valid) => valid.to_string(),
604    Err(_) => {
605      let mut lossy = String::from_utf8_lossy(raw).into_owned();
606      lossy.push_str(
607        "\nWarning:cortex:non_utf8_log the worker log was not valid UTF-8; decoded lossily\n",
608      );
609      lossy
610    },
611  }
612}
613
614/// Reads + decodes the `cortex.log` entry out of a result `.zip`. Uses the pure-Rust `zip` crate's
615/// **random-access `by_name`** — it seeks straight to `cortex.log` via the archive's central
616/// directory, never decompressing the (potentially large) converted output (the per-task hot path;
617/// ~1.4× libarchive on this op, see `docs/archive/ARCHIVE_RATIONALIZATION.md`). Returns the decoded
618/// log text, or an `Err` describing why it couldn't (a non-zip / corrupt archive, or a missing
619/// `cortex.log` → the task is left `Fatal`), rather than `.expect()`-panicking the dispatch path as
620/// the old libarchive reader did.
621fn read_cortex_log(result: &Path) -> Result<String, String> {
622  let file = File::open(result).map_err(|e| format!("cannot open result archive: {e}"))?;
623  let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a readable zip: {e}"))?;
624  let mut entry = archive
625    .by_name("cortex.log")
626    .map_err(|e| format!("no cortex.log entry: {e}"))?;
627  let mut raw = Vec::new();
628  entry
629    .read_to_end(&mut raw)
630    .map_err(|e| format!("reading cortex.log failed: {e}"))?;
631  Ok(decode_worker_log(&raw))
632}
633
634/// Generates a `TaskReport` from a result archive (`.zip`) of a `CorTeX` processing job, following
635/// the `LaTeXML` messaging conventions in its `cortex.log`. Returns **`None` when the archive is
636/// unreadable or empty** (0-byte, truncated, non-zip, or no `cortex.log` entry): that is an
637/// *infrastructure* failure — the worker returned nothing usable — **not** a conversion verdict, so
638/// we do **not** fabricate a terminal `Fatal`. The caller (sink) skips finalizing it, leaving the
639/// task in-flight for the lease reaper, which **retries** it and only dead-letters it (with a
640/// `cortex/never_completed_with_retries` message) after `MAX_DISPATCH_RETRIES` — never a silent,
641/// unretried Fatal (KNOWN_ISSUES D-18). A *readable* `cortex.log` always yields `Some` — a genuine
642/// verdict, even when it parses to `Fatal`.
643pub fn generate_report(task: Task, result: &Path) -> Option<TaskReport> {
644  let log_string = match read_cortex_log(result) {
645    Ok(log_string) => log_string,
646    Err(reason) => {
647      // Infrastructure failure (D-18): don't invent a verdict — defer to the reaper's retry path.
648      println!("-- generate_report: {reason} (result {result:?}); deferring to the reaper (D-18)");
649      return None;
650    },
651  };
652  let mut messages = Vec::new();
653  let mut status = TaskStatus::Fatal; // Fatal by default — overridden by the conversion status line.
654  // Look for the special status message - Fatal otherwise!
655  for message in parse_log(task.id, &log_string).into_iter() {
656    // Invalids are a bit of a workaround for now, they're fatal messages in latexml, but
657    // we want them separated out in cortex
658    let mut skip_message = false;
659    match message {
660      NewTaskMessage::Invalid(ref _log_invalid) => {
661        status = TaskStatus::Invalid;
662      },
663      NewTaskMessage::Info(ref _log_info) => {
664        let message_what = message.what();
665        if message.category() == "conversion" && !message_what.is_empty() {
666          // Adapt status to the CorTeX scheme: cortex_status = -(latexml_status+1)
667          let latexml_scheme_status = match message_what.parse::<i32>() {
668            Ok(num) => num,
669            Err(e) => {
670              println!(
671                "-- generate_report: failed to parse conversion status {message_what:?}: {e:?}"
672              );
673              3 // latexml raw fatal
674            },
675          };
676          let cortex_scheme_status = -(latexml_scheme_status + 1);
677          if status != TaskStatus::Invalid {
678            // Invalid status is final, and derived, all others are set directly from the log.
679            status = TaskStatus::from_raw(cortex_scheme_status);
680          }
681          skip_message = true; // do not record the status message
682        }
683      },
684      _ => {},
685    };
686    if !skip_message {
687      messages.push(message);
688    }
689  }
690
691  Some(TaskReport {
692    stand_in: false,
693    task,
694    status,
695    messages,
696  })
697}
698
699/// Returns an open file handle to the task's entry
700pub fn prepare_input_stream(task: &Task) -> Result<File, io::Error> {
701  let entry_path = Path::new(&task.entry);
702  File::open(entry_path)
703}
704
705/// The single source of truth for where a service's **result archive** lives, given a task's source
706/// `entry`. The sink writes it here and the frontend reads it back from here — previously three
707/// call sites re-derived the same `<entry-dir>/<service>.zip` three different ways (a
708/// `Path::parent` and two subtly-different regexes), so this collapses them into one.
709///
710/// `sandbox_id` carries the F-6 fix: a **sandbox** corpus shares the parent's source `entry` paths
711/// in place (owner decision: no source copy), so keying its outputs on `entry` alone would let a
712/// sandbox rerun overwrite the parent's archives. When `Some(id)` (the sandbox's own corpus id) the
713/// archive is name-scoped — `<entry-dir>/<service>.sandbox-<id>.zip` — so a sandbox's outputs never
714/// collide with the parent's (or another sandbox's). `None` keeps the historical
715/// `<entry-dir>/<service>.zip` for ordinary corpora (backward-compatible with existing archives).
716///
717/// Returns `None` if `entry` has no parent directory (a malformed/relative entry) — the caller then
718/// has no result path to write or serve.
719pub fn result_archive_path(
720  entry: &str,
721  service_name: &str,
722  sandbox_id: Option<i32>,
723) -> Option<PathBuf> {
724  let dir = Path::new(entry.trim_end())
725    .parent()
726    .and_then(Path::to_str)?;
727  let stem = match sandbox_id {
728    Some(id) => format!("{service_name}.sandbox-{id}"),
729    None => service_name.to_string(),
730  };
731  Some(PathBuf::from(format!("{dir}/{stem}.zip")))
732}
733
734/// Utility functions, until they find a better place
735pub fn utf_truncate(input: &mut String, maxsize: usize) {
736  let mut utf_maxsize = input.len();
737  if utf_maxsize >= maxsize {
738    {
739      let mut char_iter = input.char_indices();
740      while utf_maxsize >= maxsize {
741        utf_maxsize = match char_iter.next_back() {
742          Some((index, _)) => index,
743          _ => 0,
744        };
745      }
746    } // Extra {} wrap to limit the immutable borrow of char_indices()
747    input.truncate(utf_maxsize);
748  }
749  // eliminate null characters if any
750  *input = input.replace('\x00', "");
751}
752
753/// Generate a random integer useful for temporary DB marks
754pub fn random_mark() -> i32 {
755  let mut rng = rand::rng();
756  let mark_rng: u16 = rng.random();
757  i32::from(mark_rng)
758}
759
760/// Random temporary task `status` sentinel for the `mark_rerun` two-phase reset.
761///
762/// `mark_rerun` stamps the in-scope tasks with this value, then re-selects them by
763/// `status = <sentinel>` to delete their logs and flip them to `TODO`. The sentinel must be
764/// disjoint from **every** value a task can otherwise legitimately hold, or an unrelated task gets
765/// swept into the rerun: live leases occupy `[1, 65536]` (`fetch_tasks` stamps `1 + u16`), `TODO`
766/// is `0`, and completed/Blocked tasks are `< 0`. So we draw a **positive** value strictly *above*
767/// the maximum lease — in `[65537, 131072]` — which collides with none of them. (The old rerun mark
768/// reused `random_mark`'s raw `u16`, which overlapped the live-lease range and aliased `TODO` at 0;
769/// KNOWN_ISSUES R-13.) All temp marks stay `> 0`.
770pub fn rerun_mark() -> i32 {
771  // One past the largest value `fetch_tasks` can stamp on a lease (`1 + u16::MAX` = 65536).
772  const LEASE_CEILING: i32 = u16::MAX as i32 + 1;
773  LEASE_CEILING + 1 + i32::from(rand_in_range(0, u16::MAX))
774}
775
776/// Helper for generating a random i32 in a range, to avoid loading the rng crate + boilerplate
777pub fn rand_in_range(from: u16, to: u16) -> u16 {
778  let mut rng = rand::rng();
779  let mark_rng: u16 = rng.random_range(from..=to);
780  mark_rng
781}
782
783#[cfg(test)]
784mod log_decode_tests {
785  //! W-2: a non-UTF-8 worker log must degrade gracefully (decode lossily + record a warning), not
786  //! get discarded wholesale with the task force-marked Fatal. DB-free, so no L-1 teardown risk.
787  use super::{decode_worker_log, parse_log};
788  use crate::models::LogRecord;
789
790  #[test]
791  fn valid_utf8_passes_through_unchanged() {
792    let valid = "Warning:math:undefined hello\nStatus:conversion:1\n";
793    assert_eq!(decode_worker_log(valid.as_bytes()), valid);
794    assert!(
795      !decode_worker_log(valid.as_bytes()).contains("non_utf8_log"),
796      "no spurious warning is added to a clean log"
797    );
798  }
799
800  #[test]
801  fn non_utf8_decodes_lossily_not_fatal() {
802    // A stray 0xFF byte in an otherwise-real conversion log.
803    let raw = b"Warning:math:undefined bad \xFF byte\nStatus:conversion:1\n";
804    let decoded = decode_worker_log(raw);
805    // The real conversion status + the real message survive (the W-2 regression: previously the
806    // whole log was thrown away and the task force-marked Fatal over this single byte).
807    assert!(
808      decoded.contains("Status:conversion:1"),
809      "the real conversion status survives lossy decoding"
810    );
811    assert!(decoded.contains("Warning:math:undefined"));
812    assert!(
813      decoded.contains('\u{FFFD}'),
814      "the invalid byte became the Unicode replacement char"
815    );
816    // The encoding issue is recorded transparently rather than silently swallowed.
817    assert!(decoded.contains("Warning:cortex:non_utf8_log"));
818    // And it parses into multiple real messages, not a single fatal.
819    assert!(
820      parse_log(1, &decoded).len() >= 2,
821      "real messages are preserved, not collapsed into one fatal"
822    );
823  }
824
825  #[test]
826  fn warn_abbreviation_is_recognized_as_warning() {
827    // latexml-oxide historically emitted the abbreviated `Warn:` token; cortex must file it as a
828    // Warning, not silently default it to Info (the `_ => Info` arm), which once left every warning
829    // task showing "no_messages" in the report. The canonical Perl token `Warning:` behaves the
830    // same.
831    use super::NewTaskMessage;
832    let abbrev = parse_log(42, "Warn:missing_file:rotfloat.sty stubbed\n");
833    assert_eq!(abbrev.len(), 1);
834    assert!(
835      matches!(abbrev[0], NewTaskMessage::Warning(_)),
836      "abbreviated `Warn:` is filed as a Warning, not defaulted to Info"
837    );
838    let canonical = parse_log(42, "Warning:missing_file:x y\n");
839    assert!(matches!(canonical[0], NewTaskMessage::Warning(_)));
840  }
841
842  #[test]
843  fn runtime_line_parses_into_the_fields_mark_done_keys_on() {
844    use super::NewTaskMessage;
845    // New latexml-oxide format `Info:runtime_ms:<N>`: the value is the report category's drill-down
846    // `what` (mark.rs reads `what` for the denormalized runtime), details empty.
847    let new = parse_log(7, "Info:runtime_ms:12345\n");
848    assert_eq!(new.len(), 1);
849    let m = &new[0];
850    assert!(matches!(m, NewTaskMessage::Info(_)));
851    assert_eq!(m.category(), "runtime_ms");
852    assert_eq!(m.what(), "12345");
853    assert_eq!(m.details(), "");
854    // Legacy format `Info:cortex:runtime_ms <N>` must still parse the way the backward-compat arm
855    // expects: value in `details`, `what` is the literal `runtime_ms` (a mixed/rolling fleet).
856    let old = parse_log(7, "Info:cortex:runtime_ms 12345\n");
857    assert_eq!(old.len(), 1);
858    let m = &old[0];
859    assert_eq!(m.category(), "cortex");
860    assert_eq!(m.what(), "runtime_ms");
861    assert_eq!(m.details(), "12345");
862  }
863
864  #[test]
865  fn fatal_invalid_category_is_separated_into_invalid() {
866    // Perl-LaTeXML emits `Fatal('invalid', …)` for an unprocessable input (no TeX source,
867    // PDF-only, binary). cortex must separate that out as its distinct **Invalid** outcome (own
868    // report row, discounted from the total) — NOT a plain conversion Fatal — even though the
869    // severity token is `Fatal`. The bridge keys on the `invalid` category.
870    use super::NewTaskMessage;
871    let m = parse_log(
872      7,
873      "Fatal:invalid:no_tex_source no .tex files found in archive\n",
874    );
875    assert_eq!(m.len(), 1);
876    assert!(
877      matches!(m[0], NewTaskMessage::Invalid(_)),
878      "a Fatal message in the `invalid` category is filed as Invalid, not Fatal"
879    );
880    // A fatal in any OTHER category is still a plain Fatal.
881    let f = parse_log(7, "Fatal:conversion:caught boom\n");
882    assert!(matches!(f[0], NewTaskMessage::Fatal(_)));
883    // And the literal `Invalid:` severity (e.g. the sink's oversize-reject) still maps to Invalid.
884    let i = parse_log(7, "Invalid:size:too_big result exceeds cap\n");
885    assert!(matches!(i[0], NewTaskMessage::Invalid(_)));
886  }
887
888  #[test]
889  fn read_cortex_log_extracts_from_zip_and_errors_gracefully() {
890    use super::read_cortex_log;
891    use std::fs::File;
892    use std::io::Write;
893    let zip_path = std::env::temp_dir().join("cortex_read_log_unit_test.zip");
894    {
895      let mut zw = zip::ZipWriter::new(File::create(&zip_path).unwrap());
896      let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
897      // A large output entry first; cortex.log last — `by_name` must seek straight past it.
898      zw.start_file("html/index.html", opts).unwrap();
899      zw.write_all(&vec![b'x'; 200_000]).unwrap();
900      zw.start_file("cortex.log", opts).unwrap();
901      zw.write_all(b"Info:cortex:hello a worker log line\n")
902        .unwrap();
903      zw.finish().unwrap();
904    }
905    let log = read_cortex_log(&zip_path).expect("reads cortex.log out of the zip");
906    assert!(
907      log.contains("hello a worker log line"),
908      "by_name extracted the cortex.log content, got: {log:?}"
909    );
910    // A missing / non-zip path is a graceful Err (task left Fatal), never a panic.
911    assert!(read_cortex_log(std::path::Path::new("/nonexistent/x.zip")).is_err());
912    std::fs::remove_file(&zip_path).ok();
913  }
914
915  #[test]
916  fn generate_report_defers_unreadable_archives_to_the_reaper() {
917    use super::{Task, TaskStatus, generate_report};
918    use std::fs::File;
919    use std::io::Write;
920    let task = || Task {
921      id: 42,
922      service_id: 3,
923      corpus_id: 2,
924      status: 0,
925      entry: "/d/x.zip".to_string(),
926    };
927    // D-18: a 0-byte / missing / non-zip archive is an *infrastructure* failure → None, so the sink
928    // defers to the reaper (retry → dead-letter with a message) instead of a silent terminal Fatal.
929    let empty = std::env::temp_dir().join("cortex_d18_empty_result.zip");
930    File::create(&empty).unwrap(); // 0 bytes — "not a readable zip"
931    assert!(
932      generate_report(task(), &empty).is_none(),
933      "a 0-byte result archive defers to the reaper (None), never a fabricated Fatal"
934    );
935    assert!(
936      generate_report(task(), std::path::Path::new("/nonexistent/x.zip")).is_none(),
937      "a missing result archive defers to the reaper (None)"
938    );
939    // A *readable* cortex.log is always a genuine verdict — Some, even when it parses a conversion
940    // status (here conversion:0 → NoProblem).
941    let good = std::env::temp_dir().join("cortex_d18_good_result.zip");
942    {
943      let mut zw = zip::ZipWriter::new(File::create(&good).unwrap());
944      let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default();
945      zw.start_file("cortex.log", opts).unwrap();
946      zw.write_all(b"Info:conversion:0 clean run\n").unwrap();
947      zw.finish().unwrap();
948    }
949    let report =
950      generate_report(task(), &good).expect("a readable cortex.log yields a verdict (Some)");
951    assert_eq!(
952      report.status,
953      TaskStatus::NoProblem,
954      "conversion:0 status line -> NoProblem"
955    );
956    std::fs::remove_file(&empty).ok();
957    std::fs::remove_file(&good).ok();
958  }
959
960  #[test]
961  fn tab_indented_details_fold_into_the_preceding_message() {
962    // A message header followed by tab-indented continuation lines: the details fold into the one
963    // preceding message (the path that used to run through the `.expect()`).
964    let log = "Error:math:bad first detail\n\tsecond line\n\tthird line\n";
965    let messages = parse_log(7, log);
966    assert_eq!(messages.len(), 1, "one message, the details folded into it");
967    let details = messages[0].details();
968    assert!(details.contains("first detail"), "got: {details:?}");
969    assert!(details.contains("second line"), "got: {details:?}");
970    assert!(details.contains("third line"), "got: {details:?}");
971  }
972
973  #[test]
974  fn orphan_details_line_does_not_panic() {
975    // A hostile log that opens with a tab-indented "details" line before any message header must be
976    // treated as noise, never panic the dispatch path (DESIGN_PRINCIPLES: no `.expect()` on a
977    // parse).
978    let messages = parse_log(
979      7,
980      "\torphan detail with no header\nInfo:cortex:hi a real line\n",
981    );
982    assert_eq!(
983      messages.len(),
984      1,
985      "the orphan line is ignored; the real message survives"
986    );
987  }
988
989  #[test]
990  fn loaded_file_details_keep_the_full_source_path() {
991    use super::NewTaskMessage;
992    // The `(Loading ...` special case: `what` is the groupable filename, `details` the full path.
993    // tcolorbox's texmf directory is exactly 50 chars, so the old 50-char cap on the joined path
994    // dropped the filename outright and recorded a bare, unusable directory.
995    let messages = parse_log(
996      7,
997      "(Loading /usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty...\n",
998    );
999    assert_eq!(messages.len(), 1, "the Loading line parses to one message");
1000    let m = &messages[0];
1001    assert!(matches!(m, NewTaskMessage::Info(_)));
1002    assert_eq!(m.category(), "loaded_file");
1003    assert_eq!(m.what(), "tcolorbox.sty", "the filename stays groupable");
1004    assert_eq!(
1005      m.details(),
1006      "/usr/share/texlive/texmf-dist/tex/latex/tcolorbox/tcolorbox.sty",
1007      "details keeps the whole path, filename included"
1008    );
1009  }
1010
1011  #[test]
1012  fn result_archive_path_scopes_sandbox_outputs() {
1013    use super::result_archive_path;
1014    let entry = "/data/arxiv/1234/5678/source/5678.zip";
1015    // Ordinary corpus: the historical path, unchanged (backward-compatible).
1016    assert_eq!(
1017      result_archive_path(entry, "tex_to_html", None).unwrap(),
1018      std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
1019    );
1020    // Sandbox (F-6): same source dir, but a corpus-id-scoped archive name, so a sandbox rerun can
1021    // never overwrite the parent's `tex_to_html.zip`.
1022    assert_eq!(
1023      result_archive_path(entry, "tex_to_html", Some(42)).unwrap(),
1024      std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.sandbox-42.zip")
1025    );
1026    // A trailing newline (DB `text` columns carry them) is trimmed before taking the parent dir.
1027    assert_eq!(
1028      result_archive_path(&format!("{entry}\n"), "tex_to_html", None).unwrap(),
1029      std::path::PathBuf::from("/data/arxiv/1234/5678/source/tex_to_html.zip")
1030    );
1031    // No parent directory (a blank/root entry) ⇒ no result path: the caller skips the write/serve
1032    // instead of panicking.
1033    assert!(result_archive_path("", "tex_to_html", None).is_none());
1034    assert!(result_archive_path("/", "tex_to_html", None).is_none());
1035  }
1036}
1037
1038#[cfg(test)]
1039mod rerun_mark_tests {
1040  use super::rerun_mark;
1041
1042  #[test]
1043  fn rerun_mark_stays_above_the_lease_space() {
1044    // `fetch_tasks` stamps a lease as `1 + u16` (max 65536) and `TODO` is 0; the rerun sentinel
1045    // must never land in `[0, 65536]`, or it could sweep a live in-flight lease (or alias `TODO`)
1046    // into the rerun and double-dispatch it. All temp marks are `> 0`. KNOWN_ISSUES R-13.
1047    for _ in 0..10_000 {
1048      let mark = rerun_mark();
1049      assert!(
1050        mark > i32::from(u16::MAX) + 1,
1051        "rerun mark {mark} overlaps the lease space [1, 65536]"
1052      );
1053      assert!(mark > 0, "temp marks must be positive");
1054    }
1055  }
1056}
1057
1058#[cfg(test)]
1059mod entry_name_tests {
1060  use super::entry_document_name;
1061
1062  #[test]
1063  fn extracts_short_document_name() {
1064    // The motivating UX example: a download should be named by the entry, not the task id.
1065    assert_eq!(
1066      entry_document_name("/data/arxmliv/0811/0811.0417/0811.0417.zip"),
1067      "0811.0417"
1068    );
1069    // Dotted arXiv ids keep their internal dot (the extension is the final segment).
1070    assert_eq!(
1071      entry_document_name("/data/foo/2105.13573.tar"),
1072      "2105.13573"
1073    );
1074    // A trailing-whitespace entry (padded varchar) is trimmed first.
1075    assert_eq!(entry_document_name("/d/bar.tex   "), "bar");
1076    // No `/name.ext` shape -> falls back to the trimmed entry rather than erroring.
1077    assert_eq!(entry_document_name("noslash"), "noslash");
1078  }
1079}
1080
1081#[cfg(test)]
1082mod retryable_tag_tests {
1083  use super::{TaskReport, TaskStatus, parse_log};
1084  use crate::models::Task;
1085
1086  fn report(log: &str, status: TaskStatus) -> TaskReport {
1087    TaskReport {
1088      task: Task {
1089        id: 1,
1090        service_id: 3,
1091        corpus_id: 1,
1092        status: 7,
1093        entry: String::new(),
1094      },
1095      status,
1096      messages: parse_log(1, log),
1097      stand_in: false,
1098    }
1099  }
1100
1101  #[test]
1102  fn the_harness_tag_marks_a_retryable_cause() {
1103    let log = "Warning:x:y z\nInfo:cortex:retryable timeout:wallclock\nFatal:timeout:wallclock \
1104               the watchdog ended it\nStatus:conversion:3\n";
1105    let cause = report(log, TaskStatus::Fatal);
1106    assert!(cause.is_retryable());
1107    let kept: Vec<String> = cause
1108      .into_cause()
1109      .iter()
1110      .map(|m| {
1111        use crate::models::LogRecord;
1112        format!("{}:{}", m.category(), m.what())
1113      })
1114      .collect();
1115    assert!(!kept.contains(&"cortex:retryable".to_string()), "{kept:?}");
1116    assert!(kept.contains(&"timeout:wallclock".to_string()), "{kept:?}");
1117  }
1118
1119  #[test]
1120  fn a_documents_own_tag_line_is_not_a_retryable_cause() {
1121    // `\message{Info:cortex:retryable timeout:wallclock}` lands in the log, but the report is no
1122    // harness cause: not directly before the final Fatal, a Fatal of another kind, or no Fatal.
1123    for (log, status) in [
1124      (
1125        "Info:cortex:retryable timeout:wallclock\nWarning:x:y z\nStatus:conversion:1\n",
1126        TaskStatus::Warning,
1127      ),
1128      (
1129        "Info:cortex:retryable timeout:wallclock\nError:a:b c\nFatal:Timeout:Recursion \
1130         x\nStatus:conversion:3\n",
1131        TaskStatus::Fatal,
1132      ),
1133      (
1134        "Info:cortex:retryable timeout:wallclock\nFatal:Stomach:Recursion x\nStatus:conversion:3\n",
1135        TaskStatus::Fatal,
1136      ),
1137      (
1138        "Info:cortex:retryable timeout:wallclock\nFatal:timeout:wallclock x\nWarning:p:q \
1139         r\nFatal:Mouth:EoF y\nStatus:conversion:3\n",
1140        TaskStatus::Fatal,
1141      ),
1142    ] {
1143      assert!(!report(log, status).is_retryable(), "{log}");
1144    }
1145  }
1146}