Skip to main content

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}