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
//! One place the runtime's diagnostic writes go when a host owns the terminal.
//!
//! When the runtime is linked in-process, file descriptors 1 and 2 belong to
//! the embedder. A host drawing a full-screen interface cannot survive a write
//! to them: the bytes land in the live cell grid wherever the cursor happens to
//! be, and a renderer that diffs its own frame buffers emits only the cells it
//! believes changed, so a write it never saw is never repainted. The damage
//! persists until that region changes for an unrelated reason.
//!
//! A host can defend itself by capturing the descriptor, and one has, but that
//! is the wrong layer. It makes every embedder know that the runtime writes to
//! descriptors, and it captures unrelated writes from the rest of the process
//! along with the runtime's. The runtime knows which writes are its own.
//!
//! So a host installs one sink at session start and receives those writes as
//! data, with the stream named so it can label the source when it forwards. A
//! host that installs nothing keeps the descriptor, which is why an ordinary
//! command-line embedding is unchanged by this.
//!
//! ## What is routed here, and what is deliberately not
//!
//! Routed: the harness stdio facade, which is what guest code calls, and the
//! runtime's own log exporter.
//!
//! Not routed, because their stdout is the channel rather than a diagnostic:
//! the language-server, debug-adapter, agent-protocol and model-context
//! transports, and the merge-captain driver's opt-in event stream, which a
//! caller asks for by name. A sink that swallowed those would break the
//! protocol rather than protect the screen.
//!
//! Not routed, because the process owns them: the command-line front end's own
//! output, and the supervisor's startup handshake, whose stderr is a pipe its
//! parent reads rather than anybody's terminal.
use std::sync::{Arc, RwLock};
/// Which of the process's two output streams a write was headed for.
///
/// Carried rather than collapsed, because a host that forwards these needs to
/// label the source, and because a host may route them differently: a warning
/// belongs in a status area, and program output usually does not.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostStdioStream {
Stdout,
Stderr,
}
impl HostStdioStream {
/// The stream's conventional name, for a host that labels what it forwards.
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
}
}
}
/// A host's destination for runtime diagnostic writes.
///
/// `text` arrives exactly as the runtime produced it, including any trailing
/// newline, and is not guaranteed to be a whole line: a guest that prints
/// without a newline produces a partial one, and joining them is the host's
/// business because only the host knows what it is joining them into.
pub trait HostStdioSink: Send + Sync {
fn write(&self, stream: HostStdioStream, text: &str);
}
static HOST_STDIO_SINK: RwLock<Option<Arc<dyn HostStdioSink>>> = RwLock::new(None);
/// Install the sink for this process, returning whatever it replaced.
///
/// Process-global rather than per-thread on purpose. A session's work crosses
/// threads, and a sink that covered only the installing thread would leave the
/// writes a host most wants to catch still reaching the descriptor, while
/// looking installed.
pub fn install_host_stdio_sink(sink: Arc<dyn HostStdioSink>) -> Option<Arc<dyn HostStdioSink>> {
match HOST_STDIO_SINK.write() {
Ok(mut slot) => slot.replace(sink),
Err(poisoned) => poisoned.into_inner().replace(sink),
}
}
/// Give the descriptors back, returning the sink that was installed.
pub fn clear_host_stdio_sink() -> Option<Arc<dyn HostStdioSink>> {
match HOST_STDIO_SINK.write() {
Ok(mut slot) => slot.take(),
Err(poisoned) => poisoned.into_inner().take(),
}
}
/// Whether a host currently owns the runtime's diagnostic writes.
#[must_use]
pub fn host_stdio_sink_installed() -> bool {
current().is_some()
}
fn current() -> Option<Arc<dyn HostStdioSink>> {
match HOST_STDIO_SINK.read() {
Ok(slot) => slot.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// Send one diagnostic write to wherever it belongs.
///
/// The host's sink when one is installed, and the process descriptor when none
/// is, which is the default every embedding that installs nothing keeps. Every
/// routed call site goes through here rather than deciding for itself, so the
/// fallthrough exists once.
///
/// The lock is released before the sink runs, so a sink that writes through
/// the runtime again cannot deadlock against its own installation.
pub(crate) fn write(stream: HostStdioStream, text: &str) {
if let Some(sink) = current() {
sink.write(stream, text);
return;
}
write_descriptor(stream, text.as_bytes());
}
/// As [`write`], for a caller holding bytes that may not be whole UTF-8. A
/// sink takes text, so a fragment with no valid reading keeps the descriptor
/// rather than being dropped or replaced.
pub(crate) fn write_bytes(stream: HostStdioStream, bytes: &[u8]) {
match std::str::from_utf8(bytes) {
Ok(text) => write(stream, text),
Err(_) => write_descriptor(stream, bytes),
}
}
fn write_descriptor(stream: HostStdioStream, bytes: &[u8]) {
use std::io::Write as _;
let result = match stream {
HostStdioStream::Stdout => {
let mut handle = std::io::stdout().lock();
handle.write_all(bytes).and_then(|()| handle.flush())
}
HostStdioStream::Stderr => {
let mut handle = std::io::stderr().lock();
handle.write_all(bytes).and_then(|()| handle.flush())
}
};
let _ = result;
}