nova_runtime/io.rs
1//! The three standard streams' boundary.
2//!
3//! Each intrinsic acquires the process-global stream, acts, and drops it inside
4//! the one call -- the same shape `nova_rt_print`/`nova_rt_eprint` have used
5//! since phase 1. Nothing here holds an OS handle between calls, which is why
6//! these three types need no lifetime management and `File` (increment 3c) will.
7//!
8//! Payloads travel in the per-task slot table `fs` owns. No panic may cross a
9//! generated poll boundary, so nothing here unwraps, expects, indexes a slice,
10//! or formats fallibly.
11//!
12//! # Two boundary decisions, recorded here as well as in the task report that
13//! # made them
14//!
15//! **How a write's byte count reaches Nova.** The status word already carries
16//! the `IoErrorKind` on failure, so it cannot also carry a count on success
17//! without a sentinel this boundary does not have. It travels instead as a
18//! `Bytes` payload in the existing [`Slot::Buffer`], not a new `Slot` variant
19//! -- see [`stash_count`]'s own doc comment for why, and for the encoding a
20//! Nova wrapper must decode.
21//!
22//! **`write`, not `write_all`.** [`nova_rt_io_stdout_write`] and
23//! [`nova_rt_io_stderr_write`] each make one `Write::write` call and report
24//! whatever count it returns, rather than looping until the whole buffer is
25//! sent. See [`nova_rt_io_stdout_write`]'s own doc comment for why.
26
27use crate::fs::{fail, stash, Slot, OK};
28use crate::NovaStr;
29use std::io::Write as _;
30
31/// Read from `reader` into `buf`, truncate to what was actually read, and
32/// stash the result as a `Bytes` payload.
33///
34/// Factored out of [`nova_rt_io_stdin_read`] so the truncate-on-a-short-read
35/// behaviour is exercised in this module's own tests against a reader fully
36/// under the test's control (`std::io::Cursor`), rather than against the real
37/// process stdin. A unit test cannot safely drive real stdin: a blocking read
38/// with no piped input left waiting would hang the test binary rather than
39/// fail one test, on a developer's machine even though not in this
40/// environment's own non-interactive shell. `nova_rt_io_stdin_read` itself,
41/// reading real, harness-fed input, is exercised at the Nova fixture level
42/// instead (`tests/runtime`, increment 3b's later tasks).
43fn read_and_stash(mut reader: impl std::io::Read, buf: &mut Vec<u8>) -> i64 {
44 match reader.read(buf) {
45 Ok(n) => {
46 buf.truncate(n);
47 stash(Slot::Buffer, crate::bytes::gc_bytes(buf));
48 OK
49 }
50 Err(e) => fail(&e),
51 }
52}
53
54/// Read up to `max` bytes from stdin. An **empty** payload means end of stream.
55///
56/// A short read is not EOF: a terminal or pipe returns what is available. The
57/// Nova-level contract is stated in `std/io`'s `Read::read`.
58///
59/// # Safety
60/// No pointer argument, so no dereference precondition; marked `unsafe extern
61/// "C"` for uniformity with this crate's other JIT-registered symbols.
62#[no_mangle]
63pub unsafe extern "C" fn nova_rt_io_stdin_read(max: i64) -> i64 {
64 let Ok(cap) = usize::try_from(max) else {
65 crate::task::abort_with("nova_rt_io_stdin_read: negative maximum")
66 };
67 let mut buf = vec![0u8; cap];
68 let stdin = std::io::stdin();
69 read_and_stash(stdin.lock(), &mut buf)
70}
71
72/// Stash a byte count as an 8-byte little-endian `Bytes` payload in
73/// [`Slot::Buffer`].
74///
75/// **Decision 1 of 2: the count travels as a `Bytes` payload, not a new slot
76/// kind.** The alternative was a fourth `Slot` variant holding a raw `usize`
77/// rather than a GC pointer. `Slot::Buffer` already exists, already crosses
78/// into Nova as `Bytes` through the existing `nova_rt_fs_take_bytes`, and
79/// already serves more than one logical payload -- `fs.rs`'s own `Slot` doc
80/// comment makes the identical point for `String` versus `Bytes` -- so reusing
81/// it needs no new `Slot` variant and no new taker intrinsic. `fs.rs` itself
82/// only gains `pub(crate)` visibility on `stash`/`fail` (`take` stays
83/// private -- its one cross-module consumer is `crate::io`'s own tests,
84/// reached through the test-only `take_for_test` instead of widening `take`
85/// itself, final review M4) and doc-comment notes (including on the two
86/// existing takers) recording the reuse -- no behavioural change anywhere
87/// in it.
88///
89/// **Fixed-width 8 bytes, little-endian -- not the single byte a first sketch
90/// of the Nova wrapper might reach for.** A write can exceed 255 bytes, so one
91/// byte cannot hold every count an `i64` can; eight bytes hold any
92/// non-negative count `write` could ever report. **The Nova-level wrapper
93/// must decode all eight bytes back into an `Int`, not read only the first**
94/// -- this module's own `stash_count_round_trips_a_count_above_one_byte` test
95/// is exactly the case a single-byte encoding would get wrong.
96fn stash_count(n: usize) {
97 stash(
98 Slot::Buffer,
99 crate::bytes::gc_bytes(&(n as i64).to_le_bytes()),
100 );
101}
102
103/// Write `bytes` to `writer` with one `Write::write` call, and stash however
104/// many bytes it reports via [`stash_count`].
105///
106/// **Decision 2 of 2: one `write` call, not a `write_all` loop.** The
107/// original (pre-`Bytes`-amendment) `nova-spec/20-STDLIB.md` ยง4 already typed
108/// this as `Result<Int, IoError>`, mirroring Rust's own `Write::write` (a
109/// single, possibly-partial call reporting a real count) rather than
110/// `Write::write_all` (`Result<(), IoError>`, no count, because it either
111/// sends everything or fails). `std/fs`'s `write` makes the opposite,
112/// deliberate choice for the opposite reason: it calls `std::fs::write`
113/// (write-all semantics under the hood) and returns `Result<(), IoError>`
114/// with no count, precisely because it promises no partial write. A caller of
115/// *this* boundary that wants a guaranteed full write must loop on the
116/// returned count itself -- symmetric to how `Read::read_to_end`'s default
117/// body loops over `read` -- rather than this intrinsic silently hiding a
118/// short write.
119fn write_and_stash(mut writer: impl std::io::Write, bytes: &[u8]) -> i64 {
120 match writer.write(bytes) {
121 Ok(n) => {
122 stash_count(n);
123 OK
124 }
125 Err(e) => fail(&e),
126 }
127}
128
129/// Write `buf`'s bytes to stdout. See [`write_and_stash`] for why this is one
130/// `write` call rather than a `write_all` loop, and [`stash_count`] for how
131/// the reported count crosses back into Nova.
132///
133/// # Safety
134/// `buf` must point to a live `NovaStr`.
135#[no_mangle]
136pub unsafe extern "C" fn nova_rt_io_stdout_write(buf: *const NovaStr) -> i64 {
137 // SAFETY: forwarding this function's own contract.
138 let bytes = unsafe { crate::bytes::as_bytes(buf) };
139 let stdout = std::io::stdout();
140 write_and_stash(stdout.lock(), bytes)
141}
142
143/// Write `buf`'s bytes to stderr. Mirrors [`nova_rt_io_stdout_write`] exactly,
144/// against the other stream.
145///
146/// # Safety
147/// `buf` must point to a live `NovaStr`.
148#[no_mangle]
149pub unsafe extern "C" fn nova_rt_io_stderr_write(buf: *const NovaStr) -> i64 {
150 // SAFETY: forwarding this function's own contract.
151 let bytes = unsafe { crate::bytes::as_bytes(buf) };
152 let stderr = std::io::stderr();
153 write_and_stash(stderr.lock(), bytes)
154}
155
156/// Flush stdout.
157///
158/// # Safety
159/// No pointer argument, so no dereference precondition; marked `unsafe extern
160/// "C"` for uniformity with this crate's other JIT-registered symbols.
161#[no_mangle]
162pub unsafe extern "C" fn nova_rt_io_stdout_flush() -> i64 {
163 let stdout = std::io::stdout();
164 let mut lock = stdout.lock();
165 match lock.flush() {
166 Ok(()) => OK,
167 Err(e) => fail(&e),
168 }
169}
170
171/// Flush stderr. Mirrors [`nova_rt_io_stdout_flush`] exactly, against the
172/// other stream.
173///
174/// # Safety
175/// No pointer argument, so no dereference precondition; marked `unsafe extern
176/// "C"` for uniformity with this crate's other JIT-registered symbols.
177#[no_mangle]
178pub unsafe extern "C" fn nova_rt_io_stderr_flush() -> i64 {
179 let stderr = std::io::stderr();
180 let mut lock = stderr.lock();
181 match lock.flush() {
182 Ok(()) => OK,
183 Err(e) => fail(&e),
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 /// Test-only: decode the byte count [`stash_count`] stashed -- the mirror
192 /// image of that function's own 8-byte little-endian encoding.
193 ///
194 /// Not `pub(crate)`: every caller is one of this module's own tests.
195 fn take_count() -> i64 {
196 let ptr = crate::fs::take_for_test(Slot::Buffer) as *const NovaStr;
197 // SAFETY: test-only. The slot was just populated, in the same test,
198 // by either `stash_count` or a real write intrinsic's call into it;
199 // nothing has allocated since, so the payload has not been swept.
200 let bytes = unsafe { crate::bytes::as_bytes(ptr) };
201 let Ok(arr) = <[u8; 8]>::try_from(bytes) else {
202 panic!(
203 "stash_count always stashes exactly 8 bytes; got {} -- either \
204 nothing was pending or the encoding changed without updating \
205 this test helper to match",
206 bytes.len()
207 );
208 };
209 i64::from_le_bytes(arr)
210 }
211
212 /// A successful write reports the byte count through the per-task slot.
213 ///
214 /// The count travels as a payload rather than in the status word because the
215 /// status word carries the `IoErrorKind`, and a byte count and an error kind
216 /// cannot share one `i64` without a sentinel this boundary does not have.
217 #[test]
218 fn a_successful_stdout_write_reports_its_byte_count() {
219 let payload = crate::gc_bytes_for_test(b"hi");
220 let status = unsafe { nova_rt_io_stdout_write(payload) };
221 assert_eq!(status, crate::fs::OK, "writing to stdout must succeed");
222 assert_eq!(
223 take_count(),
224 2,
225 "the byte count must be the two bytes written, read back from the slot"
226 );
227 }
228
229 /// Mirrors the stdout test above, against the other stream: two separate
230 /// intrinsics that must each independently wire `write` through to
231 /// `stash_count`, not only the one the plan's own example happened to show.
232 #[test]
233 fn a_successful_stderr_write_reports_its_byte_count() {
234 let payload = crate::gc_bytes_for_test(b"bye");
235 let status = unsafe { nova_rt_io_stderr_write(payload) };
236 assert_eq!(status, crate::fs::OK, "writing to stderr must succeed");
237 assert_eq!(
238 take_count(),
239 3,
240 "the byte count must be the three bytes written, read back from the slot"
241 );
242 }
243
244 /// Writing an empty buffer is not an error: `Write::write(&[])` reports
245 /// `Ok(0)`, and that zero must still reach Nova as a successful status
246 /// with a `0` count, not be mistaken for a failure.
247 #[test]
248 fn writing_an_empty_buffer_succeeds_with_a_zero_count() {
249 let payload = crate::gc_bytes_for_test(b"");
250 let status = unsafe { nova_rt_io_stdout_write(payload) };
251 assert_eq!(
252 status,
253 crate::fs::OK,
254 "writing zero bytes must still succeed"
255 );
256 assert_eq!(take_count(), 0);
257 }
258
259 /// The count-encoding round-trips for a count above one byte, not only the
260 /// two- and three-byte cases the write tests above happen to use.
261 ///
262 /// A single-byte encoding -- the shape a first sketch of the Nova wrapper
263 /// might reach for, reading only `fs_take_bytes().byte_at(0)` -- would
264 /// silently truncate any count at or above 256. This is the case that
265 /// would catch that, if a future edit narrowed `stash_count`'s encoding
266 /// back down to one byte.
267 #[test]
268 fn stash_count_round_trips_a_count_above_one_byte() {
269 stash_count(300);
270 assert_eq!(
271 take_count(),
272 300,
273 "a byte count above 255 must round-trip exactly, not truncate to one byte"
274 );
275 }
276
277 /// A short read must truncate the stashed payload to exactly what was
278 /// read, not leave the rest of the pre-allocated buffer as zero padding.
279 ///
280 /// Exercises [`read_and_stash`] directly against a [`std::io::Cursor`]
281 /// that deliberately holds fewer bytes than the caller asked for, rather
282 /// than against real stdin -- see that function's own doc comment for
283 /// why a test cannot safely do that instead. **Kills the mutation of
284 /// deleting `buf.truncate(n)`**: measured by applying that exact
285 /// deletion, which turns this test's stashed payload into ten zero bytes
286 /// (`max`) instead of the two real bytes read (this task's report records
287 /// the result).
288 #[test]
289 fn a_short_read_truncates_to_what_was_actually_read() {
290 let mut buf = vec![0u8; 10]; // far more than the source below has
291 let status = read_and_stash(std::io::Cursor::new(b"hi".to_vec()), &mut buf);
292 assert_eq!(status, OK);
293 let ptr = crate::fs::take_for_test(Slot::Buffer) as *const NovaStr;
294 // SAFETY: just stashed by the call above; nothing has allocated since.
295 let bytes = unsafe { crate::bytes::as_bytes(ptr) };
296 assert_eq!(
297 bytes, b"hi",
298 "a short read must stash exactly what was read, not `max` zero-padded bytes"
299 );
300 }
301
302 /// An empty result means end of stream, and the stashed payload must be a
303 /// genuine zero-length `Bytes` -- the same EOF convention
304 /// `nova_rt_fs_take_bytes`'s own doc comment states for "nothing pending".
305 #[test]
306 fn an_empty_read_stashes_a_zero_length_payload() {
307 let mut buf = vec![0u8; 10];
308 let status = read_and_stash(std::io::Cursor::new(Vec::new()), &mut buf);
309 assert_eq!(status, OK);
310 let ptr = crate::fs::take_for_test(Slot::Buffer) as *const NovaStr;
311 // SAFETY: just stashed by the call above; nothing has allocated since.
312 let bytes = unsafe { crate::bytes::as_bytes(ptr) };
313 assert_eq!(
314 bytes, b"" as &[u8],
315 "an empty read must stash a zero-length payload -- the EOF signal \
316 `Read::read`'s Nova-level contract documents"
317 );
318 }
319
320 #[test]
321 fn stdout_flush_reports_ok() {
322 let status = unsafe { nova_rt_io_stdout_flush() };
323 assert_eq!(
324 status,
325 crate::fs::OK,
326 "flushing stdout must succeed under normal test conditions"
327 );
328 }
329
330 #[test]
331 fn stderr_flush_reports_ok() {
332 let status = unsafe { nova_rt_io_stderr_flush() };
333 assert_eq!(
334 status,
335 crate::fs::OK,
336 "flushing stderr must succeed under normal test conditions"
337 );
338 }
339
340 /// This module's own panic-freedom claim (module doc comment above),
341 /// mechanically pinned the same way `fs::tests::no_slot_access_can_panic_
342 /// on_a_borrow` and `bytes::tests::no_bytes_intrinsic_can_panic` pin
343 /// theirs: nothing in this file's production code may panic, because
344 /// every intrinsic here is reachable from a generated poll boundary with
345 /// no landing pad to unwind through.
346 ///
347 /// Scans only the part of this file before its own `mod tests` block, for
348 /// the same reason those two guards do, and with the same ceiling they
349 /// both point to: what keeps a split like this sound is that the
350 /// *first* occurrence of the split literal `mod tests {` is the real
351 /// boundary, not that the literal is unique in the file. It is not
352 /// unique here either -- the same text recurs elsewhere in this file,
353 /// including in this doc comment's own quoting of it above and in this
354 /// test's own `source.split("mod tests {")` call below -- and every such
355 /// occurrence is harmless only because it comes after the real
356 /// declaration, however many there turn out to be. **Do not replace this
357 /// sentence with a count**: a fixed number here goes stale the moment
358 /// anyone edits this comment again, which is why it states the property
359 /// instead. An *earlier* occurrence, such as a doc comment quoting that
360 /// exact text before the real declaration, would silently truncate the
361 /// scan right there instead, and this class of guard fails open: it
362 /// would pass while covering nothing, with no test failing to say so.
363 /// Indexing (`[i]`) is also outside what a substring scan can tell safe
364 /// from dangerous. See
365 /// `fs::tests::no_filesystem_intrinsic_registers_a_park`'s doc comment
366 /// (`fs.rs`) for the full statement of all three, including a real case
367 /// where an earlier occurrence did exactly this.
368 #[test]
369 fn no_stream_intrinsic_can_panic() {
370 let source = include_str!("io.rs");
371 let production = source.split("mod tests {").next().unwrap_or(source);
372 for needle in ["unwrap()", ".expect(", "panic!", "format!", "RefCell"] {
373 assert!(
374 !production.contains(needle),
375 "a std/io stream intrinsic must not panic: `{needle}` found in \
376 this file's production code, which is reachable from a \
377 generated poll boundary with no landing pad to unwind through"
378 );
379 }
380 }
381}