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;