Skip to content

Commit 3e9485b

Browse files
add string buffer for stdout and stderr (#316)
* add string buffer for stdout and stderr * handle case where buf can't be written * add comment, rewrite condition to handle overflow * return 0 and fmt * check if strings are frozen before writing + tests * raise and handle errors when a string buffer is frozen * use eq not match * document using frozen strings and encoding * remove parentheses * fix comments * use wasm traps when string is frozen
1 parent 8de1e4e commit 3e9485b

File tree

5 files changed

+231
-2
lines changed

5 files changed

+231
-2
lines changed

ext/src/helpers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
mod macros;
22
mod nogvl;
3+
mod output_limited_buffer;
34
mod static_id;
45
mod symbol_enum;
56
mod tmplock;
67

78
pub use nogvl::nogvl;
9+
pub use output_limited_buffer::OutputLimitedBuffer;
810
pub use static_id::StaticId;
911
pub use symbol_enum::SymbolEnum;
1012
pub use tmplock::Tmplock;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use magnus::{
2+
value::{InnerValue, Opaque, ReprValue},
3+
RString, Ruby,
4+
};
5+
use std::io;
6+
use std::io::ErrorKind;
7+
8+
/// A buffer that limits the number of bytes that can be written to it.
9+
/// If the buffer is full, it will truncate the data.
10+
/// Is used in the buffer implementations of stdout and stderr in `WasiCtx` and `WasiCtxBuilder`.
11+
pub struct OutputLimitedBuffer {
12+
buffer: Opaque<RString>,
13+
/// The maximum number of bytes that can be written to the output stream buffer.
14+
capacity: usize,
15+
}
16+
17+
impl OutputLimitedBuffer {
18+
#[must_use]
19+
pub fn new(buffer: Opaque<RString>, capacity: usize) -> Self {
20+
Self { buffer, capacity }
21+
}
22+
}
23+
24+
impl io::Write for OutputLimitedBuffer {
25+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
26+
// Append a buffer to the string and truncate when hitting the capacity.
27+
// We return the input buffer size regardless of whether we truncated or not to avoid a panic.
28+
let ruby = Ruby::get().unwrap();
29+
30+
let mut inner_buffer = self.buffer.get_inner_with(&ruby);
31+
32+
// Handling frozen case here is necessary because magnus does not check if a string is frozen before writing to it.
33+
let is_frozen = inner_buffer.as_value().is_frozen();
34+
if is_frozen {
35+
return Err(io::Error::new(
36+
ErrorKind::WriteZero,
37+
"Cannot write to a frozen buffer.",
38+
));
39+
}
40+
41+
if buf.is_empty() {
42+
return Ok(0);
43+
}
44+
45+
if inner_buffer
46+
.len()
47+
.checked_add(buf.len())
48+
.is_some_and(|val| val < self.capacity)
49+
{
50+
let amount_written = inner_buffer.write(buf)?;
51+
if amount_written < buf.len() {
52+
return Ok(amount_written);
53+
}
54+
} else {
55+
let portion = self.capacity - inner_buffer.len();
56+
let amount_written = inner_buffer.write(&buf[0..portion])?;
57+
if amount_written < portion {
58+
return Ok(amount_written);
59+
}
60+
};
61+
62+
Ok(buf.len())
63+
}
64+
65+
fn flush(&mut self) -> io::Result<()> {
66+
let ruby = Ruby::get().unwrap();
67+
68+
self.buffer.get_inner_with(&ruby).flush()
69+
}
70+
}

ext/src/ruby_api/wasi_ctx.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ use super::{
44
WasiCtxBuilder,
55
};
66
use crate::error;
7+
use crate::helpers::OutputLimitedBuffer;
78
use deterministic_wasi_ctx::build_wasi_ctx as wasi_deterministic_ctx;
89
use magnus::{
910
class, function, gc::Marker, method, prelude::*, typed_data::Obj, Error, Object, RString,
1011
RTypedData, Ruby, TypedData, Value,
1112
};
1213
use std::{borrow::Borrow, cell::RefCell, fs::File, path::PathBuf};
13-
use wasi_common::pipe::ReadPipe;
14+
use wasi_common::pipe::{ReadPipe, WritePipe};
1415
use wasi_common::WasiCtx as WasiCtxImpl;
1516

1617
/// @yard
@@ -75,6 +76,21 @@ impl WasiCtx {
7576
rb_self
7677
}
7778

79+
/// @yard
80+
/// Set stdout to write to a string buffer.
81+
/// If the string buffer is frozen, Wasm execution will raise a Wasmtime::Error error.
82+
/// No encoding checks are done on the resulting string, it is the caller's responsibility to ensure the string contains a valid encoding
83+
/// @param buffer [String] The string buffer to write to.
84+
/// @param capacity [Integer] The maximum number of bytes that can be written to the output buffer.
85+
/// @def set_stout_buffer(buffer, capacity)
86+
/// @return [WasiCtx] +self+
87+
fn set_stdout_buffer(rb_self: RbSelf, buffer: RString, capacity: usize) -> RbSelf {
88+
let inner = rb_self.inner.borrow_mut();
89+
let pipe = WritePipe::new(OutputLimitedBuffer::new(buffer.into(), capacity));
90+
inner.set_stdout(Box::new(pipe));
91+
rb_self
92+
}
93+
7894
/// @yard
7995
/// Set stderr to write to a file. Will truncate the file if it exists,
8096
/// otherwise try to create it.
@@ -88,6 +104,21 @@ impl WasiCtx {
88104
rb_self
89105
}
90106

107+
/// @yard
108+
/// Set stderr to write to a string buffer.
109+
/// If the string buffer is frozen, Wasm execution will raise a Wasmtime::Error error.
110+
/// No encoding checks are done on the resulting string, it is the caller's responsibility to ensure the string contains a valid encoding
111+
/// @param buffer [String] The string buffer to write to.
112+
/// @param capacity [Integer] The maximum number of bytes that can be written to the output buffer.
113+
/// @def set_stout_buffer(buffer, capacity)
114+
/// @return [WasiCtx] +self+
115+
fn set_stderr_buffer(rb_self: RbSelf, buffer: RString, capacity: usize) -> RbSelf {
116+
let inner = rb_self.inner.borrow_mut();
117+
let pipe = WritePipe::new(OutputLimitedBuffer::new(buffer.into(), capacity));
118+
inner.set_stderr(Box::new(pipe));
119+
rb_self
120+
}
121+
91122
pub fn from_inner(inner: WasiCtxImpl) -> Self {
92123
Self {
93124
inner: RefCell::new(inner),
@@ -105,6 +136,8 @@ pub fn init() -> Result<(), Error> {
105136
class.define_method("set_stdin_file", method!(WasiCtx::set_stdin_file, 1))?;
106137
class.define_method("set_stdin_string", method!(WasiCtx::set_stdin_string, 1))?;
107138
class.define_method("set_stdout_file", method!(WasiCtx::set_stdout_file, 1))?;
139+
class.define_method("set_stdout_buffer", method!(WasiCtx::set_stdout_buffer, 2))?;
108140
class.define_method("set_stderr_file", method!(WasiCtx::set_stderr_file, 1))?;
141+
class.define_method("set_stderr_buffer", method!(WasiCtx::set_stderr_buffer, 2))?;
109142
Ok(())
110143
}

ext/src/ruby_api/wasi_ctx_builder.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use super::{root, WasiCtx};
22
use crate::error;
3+
use crate::helpers::OutputLimitedBuffer;
34
use magnus::{
45
class, function, gc::Marker, method, typed_data::Obj, value::Opaque, DataTypeFunctions, Error,
56
Module, Object, RArray, RHash, RString, Ruby, TryConvert, TypedData,
67
};
78
use std::cell::RefCell;
89
use std::{fs::File, path::PathBuf};
9-
use wasi_common::pipe::ReadPipe;
10+
use wasi_common::pipe::{ReadPipe, WritePipe};
1011

1112
enum ReadStream {
1213
Inherit,
@@ -27,12 +28,14 @@ impl ReadStream {
2728
enum WriteStream {
2829
Inherit,
2930
Path(Opaque<RString>),
31+
Buffer(Opaque<RString>, usize),
3032
}
3133
impl WriteStream {
3234
pub fn mark(&self, marker: &Marker) {
3335
match self {
3436
Self::Inherit => (),
3537
Self::Path(v) => marker.mark(*v),
38+
Self::Buffer(v, _) => marker.mark(*v),
3639
}
3740
}
3841
}
@@ -149,6 +152,20 @@ impl WasiCtxBuilder {
149152
rb_self
150153
}
151154

155+
/// @yard
156+
/// Set stdout to write to a string buffer.
157+
/// If the string buffer is frozen, Wasm execution will raise a Wasmtime::Error error.
158+
/// No encoding checks are done on the resulting string, it is the caller's responsibility to ensure the string contains a valid encoding
159+
/// @param buffer [String] The string buffer to write to.
160+
/// @param capacity [Integer] The maximum number of bytes that can be written to the output buffer.
161+
/// @def set_stdout_buffer(buffer, capacity)
162+
/// @return [WasiCtxBuilder] +self+
163+
pub fn set_stdout_buffer(rb_self: RbSelf, buffer: RString, capacity: usize) -> RbSelf {
164+
let mut inner = rb_self.inner.borrow_mut();
165+
inner.stdout = Some(WriteStream::Buffer(buffer.into(), capacity));
166+
rb_self
167+
}
168+
152169
/// @yard
153170
/// Inherit stderr from the current Ruby process.
154171
/// @return [WasiCtxBuilder] +self+
@@ -170,6 +187,19 @@ impl WasiCtxBuilder {
170187
rb_self
171188
}
172189

190+
/// @yard
191+
/// Set stderr to write to a string buffer.
192+
/// If the string buffer is frozen, Wasm execution will raise a Wasmtime::Error error.
193+
/// No encoding checks are done on the resulting string, it is the caller's responsibility to ensure the string contains a valid encoding
194+
/// @param buffer [String] The string buffer to write to.
195+
/// @param capacity [Integer] The maximum number of bytes that can be written to the output buffer.
196+
/// @def set_stderr_buffer(buffer, capacity)
197+
/// @return [WasiCtxBuilder] +self+
198+
pub fn set_stderr_buffer(rb_self: RbSelf, buffer: RString, capacity: usize) -> RbSelf {
199+
let mut inner = rb_self.inner.borrow_mut();
200+
inner.stderr = Some(WriteStream::Buffer(buffer.into(), capacity));
201+
rb_self
202+
}
173203
/// @yard
174204
/// Set env to the specified +Hash+.
175205
/// @param env [Hash<String, String>]
@@ -216,6 +246,10 @@ impl WasiCtxBuilder {
216246
WriteStream::Path(path) => {
217247
builder.stdout(file_w(ruby.get_inner(*path)).map(wasi_file)?)
218248
}
249+
WriteStream::Buffer(buffer, capacity) => {
250+
let buf = OutputLimitedBuffer::new(*buffer, *capacity);
251+
builder.stdout(Box::new(WritePipe::new(buf)))
252+
}
219253
};
220254
}
221255

@@ -225,6 +259,10 @@ impl WasiCtxBuilder {
225259
WriteStream::Path(path) => {
226260
builder.stderr(file_w(ruby.get_inner(*path)).map(wasi_file)?)
227261
}
262+
WriteStream::Buffer(buffer, capacity) => {
263+
let buf = OutputLimitedBuffer::new(*buffer, *capacity);
264+
builder.stderr(Box::new(WritePipe::new(buf)))
265+
}
228266
};
229267
}
230268

@@ -282,12 +320,20 @@ pub fn init() -> Result<(), Error> {
282320
"set_stdout_file",
283321
method!(WasiCtxBuilder::set_stdout_file, 1),
284322
)?;
323+
class.define_method(
324+
"set_stdout_buffer",
325+
method!(WasiCtxBuilder::set_stdout_buffer, 2),
326+
)?;
285327

286328
class.define_method("inherit_stderr", method!(WasiCtxBuilder::inherit_stderr, 0))?;
287329
class.define_method(
288330
"set_stderr_file",
289331
method!(WasiCtxBuilder::set_stderr_file, 1),
290332
)?;
333+
class.define_method(
334+
"set_stderr_buffer",
335+
method!(WasiCtxBuilder::set_stderr_buffer, 2),
336+
)?;
291337

292338
class.define_method("set_env", method!(WasiCtxBuilder::set_env, 1))?;
293339

spec/unit/wasi_spec.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,84 @@ module Wasmtime
5555
expect(stdout.dig("wasi", "stdin")).to eq("stdin content")
5656
end
5757

58+
it "writes std streams to buffers" do
59+
File.write(tempfile_path("stdin"), "stdin content")
60+
61+
stdout_str = ""
62+
stderr_str = ""
63+
wasi_config = WasiCtxBuilder.new
64+
.set_stdin_file(tempfile_path("stdin"))
65+
.set_stdout_buffer(stdout_str, 40000)
66+
.set_stderr_buffer(stderr_str, 40000)
67+
.build
68+
69+
run_wasi_module(wasi_config)
70+
71+
parsed_stdout = JSON.parse(stdout_str)
72+
parsed_stderr = JSON.parse(stderr_str)
73+
expect(parsed_stdout.fetch("name")).to eq("stdout")
74+
expect(parsed_stderr.fetch("name")).to eq("stderr")
75+
end
76+
77+
it "writes std streams to buffers until capacity" do
78+
File.write(tempfile_path("stdin"), "stdin content")
79+
80+
stdout_str = ""
81+
stderr_str = ""
82+
wasi_config = WasiCtxBuilder.new
83+
.set_stdin_file(tempfile_path("stdin"))
84+
.set_stdout_buffer(stdout_str, 5)
85+
.set_stderr_buffer(stderr_str, 10)
86+
.build
87+
88+
run_wasi_module(wasi_config)
89+
90+
expect(stdout_str).to eq("{\"nam")
91+
expect(stderr_str).to eq("{\"name\":\"s")
92+
end
93+
94+
it "frozen stdout string is not written to" do
95+
File.write(tempfile_path("stdin"), "stdin content")
96+
97+
stdout_str = ""
98+
stderr_str = ""
99+
wasi_config = WasiCtxBuilder.new
100+
.set_stdin_file(tempfile_path("stdin"))
101+
.set_stdout_buffer(stdout_str, 40000)
102+
.set_stderr_buffer(stderr_str, 40000)
103+
.build
104+
105+
stdout_str.freeze
106+
expect { run_wasi_module(wasi_config) }.to raise_error do |error|
107+
expect(error).to be_a(Wasmtime::Error)
108+
expect(error.message).to match(/error while executing at wasm backtrace:/)
109+
end
110+
111+
parsed_stderr = JSON.parse(stderr_str)
112+
expect(stdout_str).to eq("")
113+
expect(parsed_stderr.fetch("name")).to eq("stderr")
114+
end
115+
it "frozen stderr string is not written to" do
116+
File.write(tempfile_path("stdin"), "stdin content")
117+
118+
stderr_str = ""
119+
stdout_str = ""
120+
wasi_config = WasiCtxBuilder.new
121+
.set_stdin_file(tempfile_path("stdin"))
122+
.set_stderr_buffer(stderr_str, 40000)
123+
.set_stdout_buffer(stdout_str, 40000)
124+
.build
125+
126+
stderr_str.freeze
127+
expect { run_wasi_module(wasi_config) }.to raise_error do |error|
128+
expect(error).to be_a(Wasmtime::Error)
129+
expect(error.message).to match(/error while executing at wasm backtrace:/)
130+
end
131+
132+
expect(stderr_str).to eq("")
133+
expect(stdout_str).to eq("")
134+
end
135+
58136
it "reads stdin from string" do
59137
env = wasi_module_env { |config| config.set_stdin_string("¡UTF-8 from Ruby!") }
60138
expect(env.fetch("stdin")).to eq("¡UTF-8 from Ruby!")

0 commit comments

Comments
 (0)