Skip to main content

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;