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