git_policy_cli/markdown_linter_guard.rs
1//! What:
2//! Run one job on a thread of its own and wait for it with a deadline,
3//! so a job that panics or never ends costs the caller one failed result,
4//! not the process.
5//! Why:
6//! The Markdown linter used to run as a child process,
7//! and the operating system gave the wrapper a free boundary:
8//! a child that hung could be killed and a child that crashed could not take the wrapper down.
9//! Linked into the wrapper,
10//! the linter shares its address space,
11//! so a boundary is rebuilt here from what the language offers,
12//! and it is a smaller one than a process gave:
13//! a hang and a panic are contained, a stack overflow, an out-of-memory abort or any other abort is not,
14//! and ends the wrapper like any other crash.
15//! The size of a finished result is bounded by the caller after the fact.
16//! The long-term answer is to run the linter in the resident service, outside the client.
17//! A panic is caught on the job's thread and returned as text.
18//! A job that outlives its deadline cannot be stopped from outside,
19//! so the wrapper stops waiting for it and leaves the thread to end with the process,
20//! which is short-lived;
21//! the thread owns everything it uses and shares nothing with the caller,
22//! so abandoning it leaves nothing locked.
23//! The thread has the linter's own stack size,
24//! because the main thread's stack is set by the platform and a stack overflow cannot be caught;
25//! the size makes an overflow as unlikely as in the executable, it does not contain one.
26//!
27//! In TS you'd write (pseudocode):
28//! ```ts
29//! // const result = await Promise.race([worker.run(job), rejectAfter(deadlineMs)]); // the worker is left running on a timeout
30//! ```
31
32/// When a long wait prints its notices.
33use super::wait_progress::{Cadence, WaitReporter};
34/// The thread builder the linter's own executable uses for everything that lints.
35use monochromatic_lint::run_workers::lint_thread;
36/// Panic containment and the payload type of a caught panic.
37use std::panic::{AssertUnwindSafe, catch_unwind};
38/// The sink of notices.
39use std::io::Write;
40/// A one-shot channel from the job's thread to the waiting caller.
41use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
42/// Spans of time and the clock.
43use std::time::{Duration, Instant};
44
45/// What:
46/// Why a guarded job produced no result.
47/// Why:
48/// Each cause names a different way forward for the person.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type GuardFailure = { kind: 'start'; reason } | { kind: 'panicked'; reason } | { kind: 'timed-out'; deadlineMs } | { kind: 'vanished' };
53/// ```
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum GuardFailure {
56 /// The operating system would not start the thread.
57 Start(String),
58 /// The job panicked; the text is the panic's message.
59 Panicked(String),
60 /// The job did not finish within the deadline and was abandoned.
61 TimedOut(Duration),
62 /// The thread ended without sending a result or a panic,
63 /// which no code path here does.
64 Vanished,
65}
66
67/// What:
68/// The text of a caught panic's payload.
69/// Why:
70/// A panic carries a `&str` or a `String`; anything else has no message to show.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// const panicText = (payload: unknown) => typeof payload === 'string' ? payload : 'a panic payload that is not text';
75/// ```
76fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
77 if let Some(message) = payload.downcast_ref::<String>() {
78 return message.clone();
79 }
80 if let Some(message) = payload.downcast_ref::<&str>() {
81 return String::from(*message);
82 }
83 return String::from("a panic payload that is not text");
84}
85
86/// What:
87/// The notice a long wait prints.
88/// Why:
89/// The linter hashes every image a file links, which takes seconds for a large one,
90/// and a silent wait looks like a hang;
91/// the notice names the file and where the wait stops.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// const noticeLine = (waitingFor, waitedMs, deadlineMs) => `cli-git: still waiting for ${waitingFor} after ...`;
96/// ```
97fn notice_line(waiting_for: &str, waited: Duration, deadline: Duration, repeat: bool) -> String {
98 let hint: &str = if repeat {
99 ""
100 } else {
101 " Hint: it is still working; if it looks stuck, wait for the limit or set the policy markdown/autofix to off for this command."
102 };
103 return format!(
104 "cli-git: still waiting for {waiting_for} after {} seconds; giving up on it after {} seconds.{hint}",
105 waited.as_secs(),
106 deadline.as_secs()
107 );
108}
109
110/// What:
111/// Wait for the job's thread to report,
112/// printing notices at the cadence,
113/// until the deadline.
114/// Why:
115/// The wait sleeps on the channel,
116/// so a job that finishes wakes it at once,
117/// and wakes by itself only when a notice or the deadline is due.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// async function waitFor<T>(receiver, deadlineMs, reporter): Promise<T>; // throws GuardFailure
122/// ```
123fn wait_for<T>(
124 receiver: &Receiver<Result<T, String>>,
125 deadline: Duration,
126 waiting_for: &str,
127 reporter: &mut WaitReporter<'_>,
128) -> Result<T, GuardFailure> {
129 let started: Instant = Instant::now();
130 loop {
131 let elapsed: Duration = started.elapsed();
132 let remaining: Duration = deadline.saturating_sub(elapsed);
133 // A notice that is due goes out before the deadline is checked, so an abandoned job was announced.
134 reporter.poll(|waited, repeat| {
135 return notice_line(waiting_for, waited, deadline, repeat);
136 });
137 if remaining.is_zero() {
138 return Err(GuardFailure::TimedOut(deadline));
139 }
140 // Wake for the deadline at the latest; a short slice keeps notices on schedule.
141 let slice: Duration = remaining.min(NOTICE_POLL);
142 match receiver.recv_timeout(slice) {
143 Ok(Ok(value)) => return Ok(value),
144 Ok(Err(reason)) => return Err(GuardFailure::Panicked(reason)),
145 Err(RecvTimeoutError::Timeout) => {}
146 Err(RecvTimeoutError::Disconnected) => return Err(GuardFailure::Vanished),
147 }
148 }
149}
150
151/// The longest the wait sleeps before it looks at the notice schedule again.
152const NOTICE_POLL: Duration = Duration::from_millis(50);
153
154/// What:
155/// Run `job` on a new thread and return its result,
156/// or why there is none.
157/// `T` is whatever the job returns;
158/// both the job and its result move between threads.
159/// Why:
160/// See the module comment.
161/// The caller names what it waits for ("monochromatic-lint to check docs/a.md"),
162/// which the notices print.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// async function runGuarded<T>(job: () => T, deadlineMs, cadence, waitingFor, notices): Promise<T>; // throws GuardFailure
167/// ```
168pub fn run_guarded<T: Send + 'static>(
169 job: impl FnOnce() -> T + Send + 'static,
170 deadline: Duration,
171 cadence: Cadence,
172 waiting_for: &str,
173 notices: &mut dyn Write,
174) -> Result<T, GuardFailure> {
175 let (sender, receiver): (Sender<Result<T, String>>, Receiver<Result<T, String>>) = channel();
176 let spawned = lint_thread().name(String::from("cli-git-markdown-lint")).spawn(move || {
177 let outcome: Result<T, String> = match catch_unwind(AssertUnwindSafe(job)) {
178 Ok(value) => Ok(value),
179 Err(payload) => Err(panic_text(payload.as_ref())),
180 };
181 // The caller may have given up on this job; a closed channel is that case, and there is nobody to tell.
182 let _abandoned = sender.send(outcome);
183 });
184 if let Err(error) = spawned {
185 return Err(GuardFailure::Start(error.to_string()));
186 }
187 let mut reporter: WaitReporter<'_> = WaitReporter::start(cadence, notices);
188 return wait_for(&receiver, deadline, waiting_for, &mut reporter);
189}
190
191/// Panic, deadline and notice controls stay out of the release executable.
192#[cfg(test)]
193#[path = "markdown_linter_guard_tests.rs"]
194mod tests;