Skip to main content

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 wire names (`intended-geometry`)
272/// rather than Rust variant names (`IntendedGeometry`), 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}