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//!  Elapsed time never 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 the wait has no end but the
30//!  job's.
31//!
32//! In TS you'd write (pseudocode):
33//! ```ts
34//! // const result = await Promise.race([worker.run(job), rejectWhenQuiet(worker, windowMs)]); // the worker is left running when it is quiet
35//! ```
36
37/// When a long wait prints its notices.
38use crate::wait_progress::{Cadence, WaitReporter};
39/// The longest quiet gap of each tool, kept for the timing records.
40use crate::quiet_ledger;
41/// The thread's own number, and the clock that says when a thread has been quiet for a window.
42use crate::thread_quiet::{ThreadClock, current_thread_id};
43/// The thread builder the linter's own executable uses for everything that lints.
44use monochromatic_lint::run_workers::lint_thread;
45/// Panic containment and the payload type of a caught panic.
46use std::panic::{AssertUnwindSafe, catch_unwind};
47/// The sink of notices.
48use std::io::Write;
49/// A one-shot channel from the job's thread to the waiting caller.
50use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
51/// Spans of time and the clock.
52use std::time::Duration;
53
54/// What:
55///  Why a guarded job produced no result.
56/// Why:
57///  Each cause names a different way forward for the person.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type GuardFailure = { kind: 'start'; reason } | { kind: 'panicked'; reason } | { kind: 'quiet'; windowMs } | { kind: 'vanished' };
62/// ```
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub enum GuardFailure {
65    /// The operating system would not start the thread.
66    Start(String),
67    /// The job panicked; the text is the panic's message.
68    Panicked(String),
69    /// The job showed no sign of work for the window and was abandoned.
70    Quiet(Duration),
71    /// The thread ended without sending a result or a panic,
72    ///  which no code path here does.
73    Vanished,
74}
75
76/// What:
77///  The text of a caught panic's payload.
78/// Why:
79///  A panic carries a `&str` or a `String`; anything else has no message to show.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// const panicText = (payload: unknown) => typeof payload === 'string' ? payload : 'a panic payload that is not text';
84/// ```
85fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
86    if let Some(message) = payload.downcast_ref::<String>() {
87        return message.clone();
88    }
89    if let Some(message) = payload.downcast_ref::<&str>() {
90        return String::from(*message);
91    }
92    return String::from("a panic payload that is not text");
93}
94
95/// What:
96///  The notice a long wait prints.
97/// Why:
98///  The linter hashes every image a file links, which takes seconds for a large one,
99///  and a silent wait looks like a hang;
100///  the notice names the file and where the wait stops.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// const noticeLine = (waitingFor, waitedMs, windowMs) => `cli-git: still waiting for ${waitingFor} after ...`;
105/// ```
106fn notice_line(waiting_for: &str, waited: Duration, window: Duration, repeat: bool) -> String {
107    let hint: &str = if repeat {
108        ""
109    } else {
110        " Hint: it is still working; if it looks stuck, wait for the window or set the policy markdown/autofix to off for this command."
111    };
112    return format!(
113        "cli-git: still waiting for {waiting_for} after {} seconds; giving up on it only after {} seconds with no sign of work.{hint}",
114        waited.as_secs(),
115        window.as_secs()
116    );
117}
118
119/// What:
120///  Wait for the job's thread to report,
121///  printing notices at the cadence,
122///  until the thread has shown no sign of work for the window.
123/// Why:
124///  The wait sleeps on the channel,
125///  so a job that finishes wakes it at once,
126///  and wakes by itself only when a notice or a look at the thread is due.
127///  The thread's number arrives first on its own channel; until it does the thread is starting, which is not quiet.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// async function waitFor<T>(receiver, ids, windowMs, reporter): Promise<T>; // throws GuardFailure
132/// ```
133fn wait_for<T>(
134    receiver: &Receiver<Result<T, String>>,
135    thread_numbers: &Receiver<i32>,
136    window: Duration,
137    waiting_for: &str,
138    reporter: &mut WaitReporter<'_>,
139) -> (Result<T, GuardFailure>, Duration) {
140    let mut clock: Option<ThreadClock> = None;
141    let mut longest: Duration = Duration::ZERO;
142    loop {
143        // A notice that is due goes out before the thread is judged, so an abandoned job was announced.
144        reporter.poll(|waited, repeat| {
145            return notice_line(waiting_for, waited, window, repeat);
146        });
147        if clock.is_none()
148            && let Ok(number) = thread_numbers.try_recv()
149        {
150            clock = Some(ThreadClock::start(number, window, false));
151        }
152        if let Some(watching) = clock.as_mut() {
153            let quiet: bool = watching.quiet();
154            longest = watching.longest_quiet();
155            if quiet {
156                return (Err(GuardFailure::Quiet(window)), longest);
157            }
158        }
159        match receiver.recv_timeout(NOTICE_POLL) {
160            Ok(Ok(value)) => return (Ok(value), longest),
161            Ok(Err(reason)) => return (Err(GuardFailure::Panicked(reason)), longest),
162            Err(RecvTimeoutError::Timeout) => {}
163            Err(RecvTimeoutError::Disconnected) => return (Err(GuardFailure::Vanished), longest),
164        }
165    }
166}
167
168/// The longest the wait sleeps before it looks at the notice schedule again.
169///  Exempt from a citation under `LQM` (class: poll): it sets how often a condition is looked at again, so it adds at
170///  most one poll interval to a wait that already exists. Classed once in `doc/planning/cli-git-shipped-limits-audit.md`,
171///  "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
172const NOTICE_POLL: Duration = Duration::from_millis(50);
173
174/// What:
175///  Run `job` on a new thread and return its result,
176///  or why there is none.
177///  `T` is whatever the job returns;
178///  both the job and its result move between threads.
179/// Why:
180///  See the module comment.
181///  The caller names what it waits for ("monochromatic-lint to check docs/a.md"),
182///  which the notices print.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// async function runGuarded<T>(job: () => T, windowMs, cadence, waitingFor, notices): Promise<T>; // throws GuardFailure
187/// ```
188pub fn run_guarded<T: Send + 'static>(
189    job: impl FnOnce() -> T + Send + 'static,
190    window: Duration,
191    cadence: Cadence,
192    waiting_for: &str,
193    notices: &mut dyn Write,
194) -> Result<T, GuardFailure> {
195    let (sender, receiver): (Sender<Result<T, String>>, Receiver<Result<T, String>>) = channel();
196    let (numbers, thread_numbers): (Sender<i32>, Receiver<i32>) = channel();
197    let spawned = lint_thread().name(String::from("cli-git-markdown-lint")).spawn(move || {
198        // The waiting side judges this thread by its number; a closed channel is a waiter that gave up.
199        if let Some(number) = current_thread_id() {
200            let _told = numbers.send(number);
201        }
202        let outcome: Result<T, String> = match catch_unwind(AssertUnwindSafe(job)) {
203            Ok(value) => Ok(value),
204            Err(payload) => Err(panic_text(payload.as_ref())),
205        };
206        // The caller may have given up on this job; a closed channel is that case, and there is nobody to tell.
207        let _abandoned = sender.send(outcome);
208    });
209    if let Err(error) = spawned {
210        return Err(GuardFailure::Start(error.to_string()));
211    }
212    let mut reporter: WaitReporter<'_> = WaitReporter::start(cadence, notices);
213    let (result, longest): (Result<T, GuardFailure>, Duration) = wait_for(&receiver, &thread_numbers, window, waiting_for, &mut reporter);
214    quiet_ledger::note("monochromatic-lint", longest, matches!(result, Err(GuardFailure::Quiet(_))));
215    return result;
216}
217
218/// Panic, quiet-window and notice controls stay out of the release executable.
219#[cfg(test)]
220#[path = "markdown_linter_guard_tests.rs"]
221mod tests;