use std::sync::{Arc, Mutex, PoisonError};
use bytes::Bytes;
use wasmtime_wasi::cli::{IsTerminal, StdoutStream};
use wasmtime_wasi::p2::{OutputStream, Pollable, StreamResult};
use crate::LogLevel;
use crate::state::{LogBudget, LogLine, sanitize_log_message};
const MAX_STDIO_LINE_BYTES: usize = 4 * 1024;
const MAX_STDIO_BYTES_PER_REQUEST: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StdioKind {
Stdout,
Stderr,
}
impl StdioKind {
fn level(self) -> LogLevel {
match self {
StdioKind::Stdout => LogLevel::Debug,
StdioKind::Stderr => LogLevel::Warn,
}
}
}
struct ByteBudget {
max_line_bytes: usize,
remaining_bytes: usize,
warned: bool,
}
impl ByteBudget {
fn new(max_line_bytes: usize, total_bytes: usize) -> Self {
Self {
max_line_bytes,
remaining_bytes: total_bytes,
warned: false,
}
}
fn reset(&mut self, total_bytes: usize) {
self.remaining_bytes = total_bytes;
self.warned = false;
}
}
impl LogBudget for ByteBudget {
fn admit(&mut self, level: LogLevel, message: String) -> Option<(LogLevel, String)> {
if self.warned {
return None;
}
let text = sanitize_log_message(message, self.max_line_bytes);
let cost = text.len().max(1);
if cost > self.remaining_bytes {
self.warned = true;
return Some((
LogLevel::Warn,
"… stdio truncated (per-request byte budget reached)".to_string(),
));
}
self.remaining_bytes -= cost;
Some((level, text))
}
}
struct StdioBridgeInner {
stdout_partial: Vec<u8>,
stderr_partial: Vec<u8>,
pending: Vec<LogLine>,
budget: ByteBudget,
}
impl StdioBridgeInner {
fn admit_line(&mut self, kind: StdioKind, raw: Vec<u8>) {
let text = String::from_utf8_lossy(&raw).into_owned();
if let Some((level, message)) = self.budget.admit(kind.level(), text) {
self.pending.push(LogLine { level, message });
}
}
fn partial(&mut self, kind: StdioKind) -> &mut Vec<u8> {
match kind {
StdioKind::Stdout => &mut self.stdout_partial,
StdioKind::Stderr => &mut self.stderr_partial,
}
}
fn write(&mut self, kind: StdioKind, bytes: &[u8]) {
let mut buf = std::mem::take(self.partial(kind));
buf.extend_from_slice(bytes);
let mut start = 0;
while let Some(rel_pos) = buf
.get(start..)
.unwrap_or_default()
.iter()
.position(|&b| b == b'\n')
{
let pos = start + rel_pos;
self.admit_line(kind, buf.get(start..pos).unwrap_or_default().to_vec());
start = pos + 1;
}
let remainder = buf.get(start..).unwrap_or_default();
if remainder.len() > MAX_STDIO_LINE_BYTES {
let line = remainder.to_vec();
self.admit_line(kind, line);
} else {
*self.partial(kind) = remainder.to_vec();
}
}
fn flush_partial(&mut self, kind: StdioKind) {
let line = std::mem::take(self.partial(kind));
if !line.is_empty() {
self.admit_line(kind, line);
}
}
fn drain(&mut self) -> Vec<LogLine> {
std::mem::take(&mut self.pending)
}
fn drain_final(&mut self) -> Vec<LogLine> {
self.flush_partial(StdioKind::Stdout);
self.flush_partial(StdioKind::Stderr);
self.drain()
}
fn begin_request(&mut self) {
self.stdout_partial.clear();
self.stderr_partial.clear();
self.pending.clear();
self.budget.reset(MAX_STDIO_BYTES_PER_REQUEST);
}
}
#[derive(Clone)]
pub(crate) struct StdioBridge {
inner: Arc<Mutex<StdioBridgeInner>>,
}
impl StdioBridge {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(StdioBridgeInner {
stdout_partial: Vec::new(),
stderr_partial: Vec::new(),
pending: Vec::new(),
budget: ByteBudget::new(MAX_STDIO_LINE_BYTES, MAX_STDIO_BYTES_PER_REQUEST),
})),
}
}
pub(crate) fn stream(&self, kind: StdioKind) -> StdioStream {
StdioStream {
bridge: self.clone(),
kind,
}
}
pub(crate) fn drain(&self) -> Vec<LogLine> {
self.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.drain()
}
pub(crate) fn drain_final(&self) -> Vec<LogLine> {
self.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.drain_final()
}
pub(crate) fn begin_request(&self) {
self.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.begin_request();
}
}
#[derive(Clone)]
pub(crate) struct StdioStream {
bridge: StdioBridge,
kind: StdioKind,
}
impl IsTerminal for StdioStream {
fn is_terminal(&self) -> bool {
false
}
}
impl StdoutStream for StdioStream {
fn async_stream(&self) -> Box<dyn tokio::io::AsyncWrite + Send + Sync> {
Box::new(tokio::io::sink())
}
fn p2_stream(&self) -> Box<dyn OutputStream> {
Box::new(self.clone())
}
}
#[wasmtime_wasi::async_trait]
impl OutputStream for StdioStream {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
self.bridge
.inner
.lock()
.unwrap_or_else(PoisonError::into_inner)
.write(self.kind, &bytes);
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
Ok(usize::MAX)
}
}
#[wasmtime_wasi::async_trait]
impl Pollable for StdioStream {
async fn ready(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
fn bridge() -> StdioBridge {
StdioBridge::new()
}
#[test]
fn splits_on_newline_and_tags_stream_levels() {
let b = bridge();
b.inner
.lock()
.unwrap()
.write(StdioKind::Stdout, b"first\nsecond\n");
b.inner.lock().unwrap().write(StdioKind::Stderr, b"oops\n");
let lines = b.drain();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].message, "first");
assert_eq!(lines[0].level, LogLevel::Debug);
assert_eq!(lines[1].message, "second");
assert_eq!(lines[2].message, "oops");
assert_eq!(lines[2].level, LogLevel::Warn);
}
#[test]
fn a_trailing_partial_line_stays_buffered_until_drained_by_the_next_write() {
let b = bridge();
b.inner
.lock()
.unwrap()
.write(StdioKind::Stdout, b"no newline yet");
assert!(
b.drain().is_empty(),
"an unterminated line is not queued until it completes or overflows"
);
b.inner.lock().unwrap().write(StdioKind::Stdout, b" done\n");
let lines = b.drain();
assert_eq!(lines[0].message, "no newline yet done");
}
#[test]
fn an_unterminated_line_past_the_per_line_cap_flushes_without_waiting_for_a_newline() {
let b = bridge();
b.inner.lock().unwrap().write(
StdioKind::Stdout,
"x".repeat(MAX_STDIO_LINE_BYTES + 100).as_bytes(),
);
let lines = b.drain();
assert_eq!(lines.len(), 1);
assert!(lines[0].message.len() <= MAX_STDIO_LINE_BYTES);
}
#[test]
fn the_per_request_byte_budget_is_combined_across_stdout_and_stderr() {
let b = bridge();
let line = "a".repeat(1024);
for _ in 0..(MAX_STDIO_BYTES_PER_REQUEST / line.len()) {
b.inner
.lock()
.unwrap()
.write(StdioKind::Stdout, format!("{line}\n").as_bytes());
}
b.inner
.lock()
.unwrap()
.write(StdioKind::Stderr, format!("{line}\n").as_bytes());
b.inner
.lock()
.unwrap()
.write(StdioKind::Stdout, b"after budget\n");
let lines = b.drain();
let truncated = lines
.iter()
.filter(|l| l.message.contains("truncated"))
.count();
assert_eq!(
truncated, 1,
"exactly one truncation marker, not one per stream"
);
assert!(
lines.iter().all(|l| l.message != "after budget"),
"a line arriving after the budget warns is dropped silently"
);
}
#[test]
fn begin_request_resets_budget_and_partial_buffers() {
let b = bridge();
b.inner.lock().unwrap().write(
StdioKind::Stdout,
"x".repeat(MAX_STDIO_BYTES_PER_REQUEST).as_bytes(),
);
b.drain();
b.begin_request();
b.inner.lock().unwrap().write(StdioKind::Stdout, b"fresh\n");
let lines = b.drain();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].message, "fresh");
}
}