termwright_protocol/debug.rs
1//! Opt-in diagnostic log for the adapter side, written to a file.
2//!
3//! The driver has its own live log (`TERMWRIGHT_DEBUG=1`, stderr, see
4//! `packages/driver/src/debug.ts`). This is the other half: what the *adapter*
5//! inside the application decided, which is the half that goes missing when a
6//! conformance run reports skips and nobody can say why the app never
7//! attached.
8//!
9//! **Never stderr.** The application under test owns the terminal; a stray
10//! line on stderr lands in the middle of a render and corrupts the very screen
11//! the driver is asserting on. So this log goes to a file the caller names, or
12//! nowhere.
13//!
14//! **Never fatal.** Every failure here — an unwritable path, a full disk, a
15//! poisoned lock — leaves the application running and the log silently off.
16//!
17//! Enable it with either variable:
18//!
19//! ```text
20//! TERMWRIGHT_DEBUG_FILE=/tmp/adapter.log # preferred
21//! TERMWRIGHT_DEBUG=/tmp/adapter.log # path, not 1/true/all
22//! ```
23//!
24//! The second form is deliberately restricted to values that are *not* the
25//! driver's own switches: `TERMWRIGHT_DEBUG=1` reaches the child process too,
26//! and if that turned this log on it would have to invent a destination for
27//! it.
28
29use std::fs::{File, OpenOptions};
30use std::io::Write;
31use std::sync::Mutex;
32use std::time::Instant;
33
34/// Names the file this log is written to. Preferred over `TERMWRIGHT_DEBUG`
35/// because it cannot collide with the driver's stderr switch.
36pub const ENV_DEBUG_FILE: &str = "TERMWRIGHT_DEBUG_FILE";
37
38/// The driver's switch, honoured here only when it carries a path.
39pub const ENV_DEBUG: &str = "TERMWRIGHT_DEBUG";
40
41/// Values of `TERMWRIGHT_DEBUG` that mean "driver-side logging" and must not
42/// be mistaken for a filename.
43const DRIVER_SWITCHES: [&str; 8] = ["0", "1", "true", "false", "on", "off", "api", "all"];
44
45const MAX_MESSAGE: usize = 400;
46
47/// Which part of the adapter a line is about, borrowed from the driver's
48/// vocabulary so one reader greps both logs.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Category {
51 /// A decision or a failure — why the adapter did or did not attach.
52 Diag,
53 /// The semantic session: dial, handshake, close.
54 Sem,
55 /// Traffic: what was published for which revision.
56 Io,
57 /// The application's own forwarded logs.
58 App,
59}
60
61impl Category {
62 fn as_str(self) -> &'static str {
63 match self {
64 Self::Diag => "diag",
65 Self::Sem => "sem",
66 Self::Io => "io",
67 Self::App => "app",
68 }
69 }
70}
71
72/// The file this process should log to, or `None` to stay silent.
73///
74/// `lookup` is the environment; pass a closure over [`std::env::var`] outside
75/// tests, which is what [`DebugLog::from_env`] does.
76pub fn debug_path<F>(lookup: F) -> Option<String>
77where
78 F: Fn(&str) -> Option<String>,
79{
80 if let Some(explicit) = lookup(ENV_DEBUG_FILE) {
81 let explicit = explicit.trim().to_owned();
82 if !explicit.is_empty() {
83 return Some(explicit);
84 }
85 }
86 let raw = lookup(ENV_DEBUG)?.trim().to_owned();
87 if raw.is_empty() || DRIVER_SWITCHES.contains(&raw.to_ascii_lowercase().as_str()) {
88 return None;
89 }
90 Some(raw)
91}
92
93/// Appends diagnostic lines to one file.
94#[derive(Debug)]
95pub struct DebugLog {
96 state: Mutex<State>,
97 started: Instant,
98}
99
100#[derive(Debug)]
101struct State {
102 file: Option<File>,
103 label: String,
104}
105
106impl DebugLog {
107 /// Open the log named by the process environment, or `None`.
108 #[must_use]
109 pub fn from_env(adapter: &str) -> Option<Self> {
110 let path = debug_path(|name| std::env::var(name).ok())?;
111 Self::open(&path, adapter)
112 }
113
114 /// Append to `path`, or return `None` when it cannot be opened.
115 ///
116 /// Returning `None` rather than an error is deliberate: a diagnostic that
117 /// refuses to start must not stop the application, and no caller has
118 /// anything to do with the failure.
119 #[must_use]
120 pub fn open(path: &str, adapter: &str) -> Option<Self> {
121 let file = OpenOptions::new()
122 .create(true)
123 .append(true)
124 .open(path)
125 .ok()?;
126 let log = Self {
127 state: Mutex::new(State {
128 file: Some(file),
129 label: format!("p{}", std::process::id()),
130 }),
131 started: Instant::now(),
132 };
133 log.line(
134 Category::Diag,
135 &format!(
136 "open adapter={adapter} pid={} platform={}/{} argv0={}",
137 std::process::id(),
138 std::env::consts::OS,
139 std::env::consts::ARCH,
140 short(&argv0()),
141 ),
142 );
143 Some(log)
144 }
145
146 /// Adopt the driver's session id once the handshake supplies one,
147 /// truncated to the eight characters the driver's own log uses.
148 pub fn set_label(&self, label: &str) {
149 if label.is_empty() {
150 return;
151 }
152 let short = label.chars().take(8).collect::<String>();
153 if let Ok(mut state) = self.state.lock() {
154 state.label = short;
155 }
156 }
157
158 /// The bracketed identifier on every line.
159 #[must_use]
160 pub fn label(&self) -> String {
161 self.state
162 .lock()
163 .map(|state| state.label.clone())
164 .unwrap_or_default()
165 }
166
167 /// Write one line. Silently does nothing once the file is gone.
168 pub fn line(&self, category: Category, message: &str) {
169 let message = if message.len() > MAX_MESSAGE {
170 let mut cut = MAX_MESSAGE;
171 while cut > 0 && !message.is_char_boundary(cut) {
172 cut -= 1;
173 }
174 format!("{}…", &message[..cut])
175 } else {
176 message.to_owned()
177 };
178 let seconds = self.started.elapsed().as_secs_f64();
179 let Ok(mut state) = self.state.lock() else {
180 return;
181 };
182 let text = format!(
183 " tw:{:<4} [{}] {:>7.3}s {message}\n",
184 category.as_str(),
185 state.label,
186 seconds,
187 );
188 let failed = match state.file.as_mut() {
189 Some(file) => file
190 .write_all(text.as_bytes())
191 .and_then(|()| file.flush())
192 .is_err(),
193 None => false,
194 };
195 if failed {
196 // The log is over; the application is not.
197 state.file = None;
198 }
199 }
200
201 /// Close the file. Safe to call more than once.
202 pub fn close(&self) {
203 if let Ok(mut state) = self.state.lock() {
204 state.file = None;
205 }
206 }
207}
208
209/// How an endpoint reads in the log: its transport and its path.
210///
211/// The endpoint is not a secret — the token is, and the token never appears
212/// here — but it is long, so it is shortened from the left, keeping the tail
213/// that distinguishes one session's socket from another's.
214#[must_use]
215pub fn describe_endpoint(endpoint: &str) -> String {
216 let kind = if endpoint.starts_with(r"\\.\pipe\") || endpoint.starts_with(r"\\?\pipe\") {
217 "pipe"
218 } else {
219 "unix"
220 };
221 format!("{kind}:{}", short(endpoint))
222}
223
224fn short(value: &str) -> String {
225 const LIMIT: usize = 60;
226 if value.chars().count() <= LIMIT {
227 return value.to_owned();
228 }
229 let tail: String = value
230 .chars()
231 .skip(value.chars().count() - (LIMIT - 1))
232 .collect();
233 format!("…{tail}")
234}
235
236fn argv0() -> String {
237 std::env::args()
238 .next()
239 .and_then(|path| {
240 std::path::Path::new(&path)
241 .file_name()
242 .map(|name| name.to_string_lossy().into_owned())
243 })
244 .unwrap_or_default()
245}
246
247/// How a negotiated switch reads in the log.
248pub(crate) fn on_off(enabled: bool) -> &'static str {
249 if enabled {
250 "on"
251 } else {
252 "off"
253 }
254}
255
256/// A one-line description of an I/O failure: kind, raw OS error and message.
257///
258/// The kind is always printed, even when the message repeats it. The kind
259/// alone is what usually settles a Windows question — `NotFound` on a pipe
260/// path means the driver was never listening, while `InvalidInput` means the
261/// path was never openable by this transport in the first place.
262pub(crate) fn error_label(error: &std::io::Error) -> String {
263 match error.raw_os_error() {
264 Some(code) => format!("{:?} [errno {code}]: {error}", error.kind()),
265 None => format!("{:?}: {error}", error.kind()),
266 }
267}
268
269/// The announced capability set as one log field, comma separated.
270///
271/// Rendered through serde so the log shows the wire names (`absolute-bounds`)
272/// rather than the Rust variant names (`AbsoluteBounds`), and so this line
273/// reads the same in all three clients.
274pub(crate) fn join_capabilities(capabilities: &[crate::roles::Capability]) -> String {
275 capabilities
276 .iter()
277 .map(|capability| {
278 serde_json::to_string(capability)
279 .unwrap_or_default()
280 .trim_matches('"')
281 .to_owned()
282 })
283 .collect::<Vec<_>>()
284 .join(",")
285}