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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
//! What:
//! 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
//! window, 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 shows no sign of work for the window 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.
//! Elapsed time never ends the wait (owner, 2026-11-03: "Stop only on no progress"; pick 9 of
//! `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the owner's delegation and open to the owner's
//! veto). The signs of work are those the push reads, taken from the job thread's own files under `/proc/self/task/`
//! (`thread_quiet.rs`): the bytes it read and wrote, its processor time, its state. The longest quiet gap of every run is kept
//! in the ledger under `monochromatic-lint` (`quiet_ledger.rs`). Where `/proc` cannot be read the wait has no end but the
//! job's.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! // const result = await Promise.race([worker.run(job), rejectWhenQuiet(worker, windowMs)]); // the worker is left running when it is quiet
//! ```
/// When a long wait prints its notices.
use super::wait_progress::{Cadence, WaitReporter};
/// The longest quiet gap of each tool, kept for the timing records.
use super::quiet_ledger;
/// The thread's own number, and the clock that says when a thread has been quiet for a window.
use super::thread_quiet::{ThreadClock, current_thread_id};
/// 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;
/// 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: 'quiet'; windowMs } | { 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 showed no sign of work for the window and was abandoned.
Quiet(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, windowMs) => `cli-git: still waiting for ${waitingFor} after ...`;
/// ```
fn notice_line(waiting_for: &str, waited: Duration, window: Duration, repeat: bool) -> String {
let hint: &str = if repeat {
""
} else {
" Hint: it is still working; if it looks stuck, wait for the window 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 only after {} seconds with no sign of work.{hint}",
waited.as_secs(),
window.as_secs()
);
}
/// What:
/// Wait for the job's thread to report,
/// printing notices at the cadence,
/// until the thread has shown no sign of work for the window.
/// 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 a look at the thread is due.
/// The thread's number arrives first on its own channel; until it does the thread is starting, which is not quiet.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function waitFor<T>(receiver, ids, windowMs, reporter): Promise<T>; // throws GuardFailure
/// ```
fn wait_for<T>(
receiver: &Receiver<Result<T, String>>,
thread_numbers: &Receiver<i32>,
window: Duration,
waiting_for: &str,
reporter: &mut WaitReporter<'_>,
) -> (Result<T, GuardFailure>, Duration) {
let mut clock: Option<ThreadClock> = None;
let mut longest: Duration = Duration::ZERO;
loop {
// A notice that is due goes out before the thread is judged, so an abandoned job was announced.
reporter.poll(|waited, repeat| {
return notice_line(waiting_for, waited, window, repeat);
});
if clock.is_none()
&& let Ok(number) = thread_numbers.try_recv()
{
clock = Some(ThreadClock::start(number, window, false));
}
if let Some(watching) = clock.as_mut() {
let quiet: bool = watching.quiet();
longest = watching.longest_quiet();
if quiet {
return (Err(GuardFailure::Quiet(window)), longest);
}
}
match receiver.recv_timeout(NOTICE_POLL) {
Ok(Ok(value)) => return (Ok(value), longest),
Ok(Err(reason)) => return (Err(GuardFailure::Panicked(reason)), longest),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return (Err(GuardFailure::Vanished), longest),
}
}
}
/// The longest the wait sleeps before it looks at the notice schedule again.
/// Exempt from a citation under `LQM` (class: poll): it sets how often a condition is looked at again, so it adds at
/// most one poll interval to a wait that already exists. Classed once in `doc/planning/cli-git-shipped-limits-audit.md`,
/// "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
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, windowMs, cadence, waitingFor, notices): Promise<T>; // throws GuardFailure
/// ```
pub fn run_guarded<T: Send + 'static>(
job: impl FnOnce() -> T + Send + 'static,
window: 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 (numbers, thread_numbers): (Sender<i32>, Receiver<i32>) = channel();
let spawned = lint_thread().name(String::from("cli-git-markdown-lint")).spawn(move || {
// The waiting side judges this thread by its number; a closed channel is a waiter that gave up.
if let Some(number) = current_thread_id() {
let _told = numbers.send(number);
}
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);
let (result, longest): (Result<T, GuardFailure>, Duration) = wait_for(&receiver, &thread_numbers, window, waiting_for, &mut reporter);
quiet_ledger::note("monochromatic-lint", longest, matches!(result, Err(GuardFailure::Quiet(_))));
return result;
}
/// Panic, quiet-window and notice controls stay out of the release executable.
#[cfg(test)]
#[path = "markdown_linter_guard_tests.rs"]
mod tests;