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;