Skip to main content

git_policy_cli/markdown/
markdown_linter_guard.rs

1//! What:
2//!  Run one job on a thread of its own and wait for it until it ends, or until the thread has shown no sign of work for a
3//!  window, so a job that panics or never ends costs the caller one failed result, not the process.
4//! Why:
5//!  The Markdown linter used to run as a child process,
6//!  and the operating system gave the wrapper a free boundary:
7//!  a child that hung could be killed and a child that crashed could not take the wrapper down.
8//!  Linked into the wrapper,
9//!  the linter shares its address space,
10//!  so a boundary is rebuilt here from what the language offers,
11//!  and it is a smaller one than a process gave:
12//!  a hang and a panic are contained, a stack overflow, an out-of-memory abort or any other abort is not,
13//!  and ends the wrapper like any other crash.
14//!  The size of a finished result is bounded by the caller after the fact.
15//!  The long-term answer is to run the linter in the resident service, outside the client.
16//!  A panic is caught on the job's thread and returned as text.
17//!  A job that shows no sign of work for the window cannot be stopped from outside,
18//!  so the wrapper stops waiting for it and leaves the thread to end with the process,
19//!  which is short-lived;
20//!  the thread owns everything it uses and shares nothing with the caller,
21//!  so abandoning it leaves nothing locked.
22//!  The thread has the linter's own stack size,
23//!  because the main thread's stack is set by the platform and a stack overflow cannot be caught;
24//!  the size makes an overflow as unlikely as in the executable, it does not contain one.
25//!  Silence ends the wait (owner, 2026-11-03: "Stop only on no progress"; pick 9 of
26//!  `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the owner's delegation and open to the owner's
27//!  veto). The signs of work are those the push reads, taken from the job thread's own files under `/proc/self/task/`
28//!  (`thread_quiet.rs`): the bytes it read and wrote, its processor time, its state. The longest quiet gap of every run is kept
29//!  in the ledger under `monochromatic-lint` (`quiet_ledger.rs`). Where `/proc` cannot be read only the ceiling ends the wait.
30//!  The ceiling (`wait_ceilings.rs`, `LINT_CEILING`) abandons a job that keeps showing work (owner, 2026-11-04, "Silence plus a
31//!  ceiling (Recommended)"), the same way: the wrapper stops waiting and the thread ends with the process.
32//!
33//! In TS you'd write (pseudocode):
34//! ```ts
35//! // const result = await Promise.race([worker.run(job), rejectWhenQuiet(worker, windowMs)]); // the worker is left running when it is quiet
36//! ```
37
38/// When a long wait prints its notices.
39use crate::wait_progress::{Cadence, WaitReporter};
40/// The longest quiet gap of each tool, kept for the timing records.
41use crate::quiet_ledger;
42/// The thread's own number, and the clock that says when a thread has been quiet for a window.
43use crate::thread_quiet::{ThreadClock, current_thread_id};
44/// The thread builder the linter's own executable uses for everything that lints.
45use monochromatic_lint::run_workers::lint_thread;
46/// Panic containment and the payload type of a caught panic.
47use std::panic::{AssertUnwindSafe, catch_unwind};
48/// The sink of notices.
49use std::io::Write;
50/// A one-shot channel from the job's thread to the waiting caller.
51use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
52/// Spans of time and the clock.
53use std::time::{Duration, Instant};
54
55/// What:
56///  Why a guarded job produced no result.
57/// Why:
58///  Each cause names a different way forward for the person.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// type GuardFailure = { kind: 'start'; reason } | { kind: 'panicked'; reason } | { kind: 'quiet'; windowMs } | { kind: 'ceiling'; ceilingMs } | { kind: 'vanished' };
63/// ```
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub enum GuardFailure {
66    /// The operating system would not start the thread.
67    Start(String),
68    /// The job panicked; the text is the panic's message.
69    Panicked(String),
70    /// The job showed no sign of work for the window and was abandoned.
71    Quiet(Duration),
72    /// The job ran as long as the ceiling allows, whatever it showed, and was abandoned.
73    Ceiling(Duration),
74    /// The thread ended without sending a result or a panic,
75    ///  which no code path here does.
76    Vanished,
77}
78
79/// What:
80///  How long a guarded job may stay silent and how long it may run in all.
81/// Why:
82///   The window abandons a job that shows nothing; the ceiling abandons one that keeps showing something (owner, 2026-11-04,
83///  "Silence plus a ceiling (Recommended)").
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type GuardLimits = { window: number; ceiling: number };
88/// ```
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct GuardLimits {
91    /// How long the job may show no sign of work.
92    pub window: Duration,
93    /// The longest the wrapper waits for the job, whatever it shows.
94    pub ceiling: Duration,
95}
96
97/// What:
98///  The text of a caught panic's payload.
99/// Why:
100///  A panic carries a `&str` or a `String`; anything else has no message to show.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// const panicText = (payload: unknown) => typeof payload === 'string' ? payload : 'a panic payload that is not text';
105/// ```
106fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
107    if let Some(message) = payload.downcast_ref::<String>() {
108        return message.clone();
109    }
110    if let Some(message) = payload.downcast_ref::<&str>() {
111        return String::from(*message);
112    }
113    return String::from("a panic payload that is not text");
114}
115
116/// What:
117///  The notice a long wait prints.
118/// Why:
119///  The linter hashes every image a file links, which takes seconds for a large one,
120///  and a silent wait looks like a hang;
121///  the notice names the file and where the wait stops.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// const noticeLine = (waitingFor, waitedMs, windowMs) => `cli-git: still waiting for ${waitingFor} after ...`;
126/// ```
127fn notice_line(waiting_for: &str, waited: Duration, window: Duration, repeat: bool) -> String {
128    let hint: &str = if repeat {
129        ""
130    } else {
131        " Hint: it is still working; if it looks stuck, wait for the window or set the policy markdown/autofix to off for this command."
132    };
133    return format!(
134        "cli-git: still waiting for {waiting_for} after {} seconds; giving up on it only after {} seconds with no sign of work.{hint}",
135        waited.as_secs(),
136        window.as_secs()
137    );
138}
139
140/// What:
141///  Wait for the job's thread to report,
142///  printing notices at the cadence,
143///  until the thread has shown no sign of work for the window.
144/// Why:
145///  The wait sleeps on the channel,
146///  so a job that finishes wakes it at once,
147///  and wakes by itself only when a notice or a look at the thread is due.
148///  The thread's number arrives first on its own channel; until it does the thread is starting, which is not quiet.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// async function waitFor<T>(receiver, ids, windowMs, reporter): Promise<T>; // throws GuardFailure
153/// ```
154fn wait_for<T>(
155    receiver: &Receiver<Result<T, String>>,
156    thread_numbers: &Receiver<i32>,
157    limits: GuardLimits,
158    waiting_for: &str,
159    reporter: &mut WaitReporter<'_>,
160) -> (Result<T, GuardFailure>, Duration) {
161    let window: Duration = limits.window;
162    let began: Instant = Instant::now();
163    let mut clock: Option<ThreadClock> = None;
164    let mut longest: Duration = Duration::ZERO;
165    loop {
166        // A notice that is due goes out before the thread is judged, so an abandoned job was announced.
167        reporter.poll(|waited, repeat| {
168            return notice_line(waiting_for, waited, window, repeat);
169        });
170        if clock.is_none()
171            && let Ok(number) = thread_numbers.try_recv()
172        {
173            clock = Some(ThreadClock::start(number, window, false));
174        }
175        if let Some(watching) = clock.as_mut() {
176            let quiet: bool = watching.quiet();
177            longest = watching.longest_quiet();
178            if quiet {
179                return (Err(GuardFailure::Quiet(window)), longest);
180            }
181        }
182        // The ceiling abandons a job that keeps showing work, as the window abandons one that shows none (owner, 2026-11-04,
183        // "Silence plus a ceiling (Recommended)").
184        if began.elapsed() >= limits.ceiling {
185            return (Err(GuardFailure::Ceiling(limits.ceiling)), longest);
186        }
187        match receiver.recv_timeout(NOTICE_POLL) {
188            Ok(Ok(value)) => return (Ok(value), longest),
189            Ok(Err(reason)) => return (Err(GuardFailure::Panicked(reason)), longest),
190            Err(RecvTimeoutError::Timeout) => {}
191            Err(RecvTimeoutError::Disconnected) => return (Err(GuardFailure::Vanished), longest),
192        }
193    }
194}
195
196/// The longest the wait sleeps before it looks at the notice schedule again.
197///  Exempt from a citation under `LQM` (class: poll): it sets how often a condition is looked at again, so it adds at
198///  most one poll interval to a wait that already exists. Classed once in `doc/planning/cli-git-shipped-limits-audit.md`,
199///  "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
200const NOTICE_POLL: Duration = Duration::from_millis(50);
201
202/// What:
203///  Run `job` on a new thread and return its result,
204///  or why there is none.
205///  `T` is whatever the job returns;
206///  both the job and its result move between threads.
207/// Why:
208///  See the module comment.
209///  The caller names what it waits for ("monochromatic-lint to check docs/a.md"),
210///  which the notices print.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// async function runGuarded<T>(job: () => T, { windowMs, ceilingMs }, cadence, waitingFor, notices): Promise<T>; // throws GuardFailure
215/// ```
216pub fn run_guarded<T: Send + 'static>(
217    job: impl FnOnce() -> T + Send + 'static,
218    limits: GuardLimits,
219    cadence: Cadence,
220    waiting_for: &str,
221    notices: &mut dyn Write,
222) -> Result<T, GuardFailure> {
223    let (sender, receiver): (Sender<Result<T, String>>, Receiver<Result<T, String>>) = channel();
224    let (numbers, thread_numbers): (Sender<i32>, Receiver<i32>) = channel();
225    let spawned = lint_thread().name(String::from("cli-git-markdown-lint")).spawn(move || {
226        // The waiting side judges this thread by its number; a closed channel is a waiter that gave up.
227        if let Some(number) = current_thread_id() {
228            let _told = numbers.send(number);
229        }
230        let outcome: Result<T, String> = match catch_unwind(AssertUnwindSafe(job)) {
231            Ok(value) => Ok(value),
232            Err(payload) => Err(panic_text(payload.as_ref())),
233        };
234        // The caller may have given up on this job; a closed channel is that case, and there is nobody to tell.
235        let _abandoned = sender.send(outcome);
236    });
237    if let Err(error) = spawned {
238        return Err(GuardFailure::Start(error.to_string()));
239    }
240    let mut reporter: WaitReporter<'_> = WaitReporter::start(cadence, notices);
241    let (result, longest): (Result<T, GuardFailure>, Duration) = wait_for(&receiver, &thread_numbers, limits, waiting_for, &mut reporter);
242    quiet_ledger::note("monochromatic-lint", longest, matches!(result, Err(GuardFailure::Quiet(_))));
243    return result;
244}
245
246/// Panic, quiet-window and notice controls stay out of the release executable.
247#[cfg(test)]
248#[path = "markdown_linter_guard_tests.rs"]
249mod tests;