Skip to content

Commit dde1550

Browse files
committed
fix(io): read-line reads process stdin — *in* root is now a stdin reader
Smell-audited: 1: the user-filed bug note suspected a TTY-only line-editor path; the actual root cause was *in*'s nil root (D-414's known "no process-stdin reader yet" hole) — read-line returned nil EVERYWHERE including interactive terminals, not just pipes. rt/__stdin-reader mints a text_io Reader in stdin mode: demand-filled via blocking std.posix.read (clj System.in parity) — readLine buffers until a full line or EOF, .read/.peek buffer one codepoint (chunk-boundary multibyte-safe); a zero-byte read latches stdin_eof. core.clj's *in* root is now (rt/__stdin-reader); with-in-str still shadows it with a string reader via binding. Fixes `printf 'x\n' | cljw -e '(read-line)'` -> nil (clj/bb read the line); unblocks cw-arcade rush-hour's piped playthrough. e2e phase16_stdin_read_line (6 cases: pipe/redirect/EOF-line/CRLF/ with-in-str-shadow/.read codepoints) + registered in run_all.sh same commit; line-seq over *in* verified.
1 parent e42ffac commit dde1550

4 files changed

Lines changed: 124 additions & 9 deletions

File tree

‎src/lang/clj/clojure/core.clj‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@
5050
;; capture are plain `(binding [*out* (rt/__string-writer)] …)` rebinds.
5151
(def ^:dynamic *out* (rt/__stdout-writer))
5252
(def ^:dynamic *err* (rt/__stderr-writer))
53-
;; `*in*` — the bindable input reader var (D-414). ROOT is nil (no process-stdin
54-
;; reader yet); `with-in-str` / `(binding [*in* rdr] …)` set it to a host reader
55-
;; (e.g. `(rt/__string-reader s)`). `read-line` derefs it.
56-
(def ^:dynamic *in* nil)
53+
;; `*in*` — the bindable input reader var (D-414). ROOT is a process-stdin
54+
;; reader (demand-filled, blocking — clj `System.in` parity), so `(read-line)`
55+
;; works on piped/redirected/interactive stdin; `with-in-str` /
56+
;; `(binding [*in* rdr] …)` rebind it to a string reader.
57+
(def ^:dynamic *in* (rt/__stdin-reader))
5758
;; `*print-length*` / `*print-level*` (ADR-0088) are interned in Zig
5859
;; (`bootstrap.registerPrintLimitVars`) alongside the other cached-pointer
5960
;; dynamic vars (*ns*, *data-readers*) so the renderer reads them via a cached
@@ -912,9 +913,9 @@
912913
nil
913914
(cons line (lazy-seq (line-seq rdr)))))))
914915

915-
;; `(read-line)` — read the next line from `*in*` (a host reader), or nil at EOF
916-
;; / when `*in*` is unbound (D-414; cljw has no process-stdin reader yet, so the
917-
;; root nil yields nil rather than blocking). Bind `*in*` via `with-in-str`.
916+
;; `(read-line)` — read the next line from `*in*` (a host reader), or nil at
917+
;; EOF. The root is a process-stdin reader (blocking, clj parity); a nil-bound
918+
;; `*in*` still yields nil rather than raising.
918919
(def read-line
919920
(fn* [] (when (some? *in*) (.readLine *in*))))
920921

‎src/runtime/io/text_io.zig‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,53 @@ const ReaderState = struct {
160160
pos: usize,
161161
/// 1-slot codepoint pushback (`.unread`); consulted first by `.read`/`.peek`.
162162
pushback: ?u21,
163+
/// stdin mode (the `*in*` root reader): `data` is a demand-filled buffer
164+
/// pulled from process stdin (blocking, matching clj's `System.in`) rather
165+
/// than a fixed string. False for `with-in-str` string readers.
166+
stdin: bool = false,
167+
/// stdin mode: a zero-byte read happened — no more fills.
168+
stdin_eof: bool = false,
163169
};
164170

171+
/// stdin mode: pull one chunk from process stdin into `data`. Returns false
172+
/// when EOF (or a read error, treated as EOF) ends the stream. Blocking on a
173+
/// TTY is the correct clj-parity behaviour (read-line waits for input).
174+
fn fillFromStdin(rt: *Runtime, st: *ReaderState) bool {
175+
if (st.stdin_eof) return false;
176+
var chunk: [4096]u8 = undefined;
177+
const n = std.posix.read(std.Io.File.stdin().handle, &chunk) catch {
178+
st.stdin_eof = true;
179+
return false;
180+
};
181+
if (n == 0) {
182+
st.stdin_eof = true;
183+
return false;
184+
}
185+
st.data.appendSlice(rt.gc.infra, chunk[0..n]) catch {
186+
st.stdin_eof = true;
187+
return false;
188+
};
189+
return true;
190+
}
191+
192+
/// stdin mode: buffer until a full line (`\n` past `pos`) or EOF is present,
193+
/// so the readLine slice logic sees the same world a string reader does.
194+
fn ensureLineBuffered(rt: *Runtime, st: *ReaderState) void {
195+
if (!st.stdin) return;
196+
while (std.mem.findScalarPos(u8, st.data.items, st.pos, '\n') == null and !st.stdin_eof) {
197+
if (!fillFromStdin(rt, st)) break;
198+
}
199+
}
200+
201+
/// stdin mode: buffer at least one full codepoint (≤ 4 bytes) past `pos` or
202+
/// EOF, so `decodeAt` never mistakes a chunk-boundary split for invalid UTF-8.
203+
fn ensureCodepointBuffered(rt: *Runtime, st: *ReaderState) void {
204+
if (!st.stdin) return;
205+
while (st.data.items.len < st.pos + 4 and !st.stdin_eof) {
206+
if (!fillFromStdin(rt, st)) break;
207+
}
208+
}
209+
165210
fn readerStateOf(recv: Value) *ReaderState {
166211
return @ptrFromInt(host_instance.asHostInstance(recv).state[0]);
167212
}
@@ -196,26 +241,26 @@ fn decodeAt(st: *ReaderState) ?Decoded {
196241
/// `(.read r)` — next codepoint as an int (JVM Reader.read), -1 at EOF. Honors a
197242
/// pending `.unread` first.
198243
fn readCharMethod(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
199-
_ = rt;
200244
_ = env;
201245
try error_catalog.checkArity("read", args, 1, loc);
202246
const st = readerStateOf(args[0]);
203247
if (st.pushback) |cp| {
204248
st.pushback = null;
205249
return Value.initInteger(@intCast(cp));
206250
}
251+
ensureCodepointBuffered(rt, st);
207252
const d = decodeAt(st) orelse return Value.initInteger(-1);
208253
st.pos += d.len;
209254
return Value.initInteger(@intCast(d.cp));
210255
}
211256

212257
/// `(.peek r)` — next codepoint as an int WITHOUT advancing; -1 at EOF.
213258
fn peekCharMethod(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
214-
_ = rt;
215259
_ = env;
216260
try error_catalog.checkArity("peek", args, 1, loc);
217261
const st = readerStateOf(args[0]);
218262
if (st.pushback) |cp| return Value.initInteger(@intCast(cp));
263+
ensureCodepointBuffered(rt, st);
219264
const d = decodeAt(st) orelse return Value.initInteger(-1);
220265
return Value.initInteger(@intCast(d.cp));
221266
}
@@ -241,6 +286,9 @@ fn readLineMethod(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
241286
_ = env;
242287
try error_catalog.checkArity("readLine", args, 1, loc);
243288
const st = readerStateOf(args[0]);
289+
// stdin mode: buffer the full line first so both branches below see the
290+
// same world a string reader does (D-414 stdin root).
291+
ensureLineBuffered(rt, st);
244292
// Fast path: no pushback → return a slice of the source (cf. host_stream).
245293
if (st.pushback == null) {
246294
const items = st.data.items;
@@ -434,6 +482,18 @@ fn inReaderFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation)
434482
return mintReader(rt, string_mod.asString(args[0]));
435483
}
436484

485+
/// `(rt/__stdin-reader)` — a text_io Reader over PROCESS stdin (demand-filled,
486+
/// blocking; clj `System.in` parity). The `*in*` ROOT (core.clj) — so
487+
/// `(read-line)` works on piped/redirected/interactive stdin, not just under
488+
/// `with-in-str` (the 2026-07-06 non-TTY read-line-nil bug).
489+
fn stdinReaderFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
490+
_ = env;
491+
try error_catalog.checkArity("__stdin-reader", args, 0, loc);
492+
const st = try rt.gc.infra.create(ReaderState);
493+
st.* = .{ .data = .empty, .pos = 0, .pushback = null, .stdin = true };
494+
return host_instance.alloc(rt, &reader_descriptor, .{ @intFromPtr(st), 0, 0, 0 });
495+
}
496+
437497
/// `(rt/__writer->str w)` — the accumulated string of a writer (`.string`
438498
/// mode; process modes own no buffer so return ""). Backs `with-out-str`.
439499
fn writerToStrFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
@@ -451,6 +511,7 @@ const PRIMS = [_]Prim{
451511
.{ .name = "__string-writer", .f = &stringWriterFn },
452512
.{ .name = "__writer->str", .f = &writerToStrFn },
453513
.{ .name = "__in-reader", .f = &inReaderFn },
514+
.{ .name = "__stdin-reader", .f = &stdinReaderFn },
454515
};
455516

456517
/// Register the text_io `rt/__*` writer primitives. Called from
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# test/e2e/phase16_stdin_read_line.sh — `(read-line)` on process stdin.
3+
#
4+
# The 2026-07-06 user-filed bug: `*in*`'s root was nil (no process-stdin
5+
# reader), so `(read-line)` returned nil on piped/redirected/interactive
6+
# stdin while clj/bb read the lines. The root is now `(rt/__stdin-reader)`
7+
# — a demand-filled blocking reader over process stdin (clj System.in
8+
# parity). Layer 2 (e2e CLI) per ADR-0021; non-TTY stdin is exactly what
9+
# this harness provides, so the regression is CI-visible.
10+
11+
set -euo pipefail
12+
cd "$(dirname "$0")/../.."
13+
14+
BIN="zig-out/bin/cljw"
15+
[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
16+
17+
fail() { echo "FAIL $1" >&2; exit 1; }
18+
assert_eq() { local n="$1" g="$2" w="$3"; [[ "$g" == "$w" ]] || fail "$n: got '$g' want '$w'"; echo "PASS $n -> $w"; }
19+
20+
# Piped stdin: two lines then EOF (clj: ["hello" "world" nil]).
21+
assert_eq 'pipe_lines' \
22+
"$(printf 'hello\nworld\n' | "$BIN" -e '(pr-str [(read-line) (read-line) (read-line)])' | tail -1)" \
23+
'"[\"hello\" \"world\" nil]"'
24+
25+
# File redirect behaves the same (not pipe-specific).
26+
tmp_in="$(mktemp)"; printf 'redirected\n' > "$tmp_in"
27+
assert_eq 'redirect_line' \
28+
"$("$BIN" -e '(read-line)' < "$tmp_in" | tail -1)" \
29+
'"redirected"'
30+
rm -f "$tmp_in"
31+
32+
# A final line WITHOUT a trailing newline still reads (EOF-terminated line).
33+
assert_eq 'no_trailing_newline' \
34+
"$(printf 'lastline' | "$BIN" -e '(pr-str [(read-line) (read-line)])' | tail -1)" \
35+
'"[\"lastline\" nil]"'
36+
37+
# CRLF terminators are stripped (the reader's \r\n handling on stdin).
38+
assert_eq 'crlf' \
39+
"$(printf 'crlf\r\n' | "$BIN" -e '(read-line)' | tail -1)" \
40+
'"crlf"'
41+
42+
# with-in-str still shadows the stdin root (string reader wins inside).
43+
assert_eq 'with_in_str_shadows' \
44+
"$(printf 'outer\n' | "$BIN" -e '(pr-str [(with-in-str "inner\n" (read-line)) (read-line)])' | tail -1)" \
45+
'"[\"inner\" \"outer\"]"'
46+
47+
# .read on stdin: codepoints stream through (multibyte-safe demand fill).
48+
assert_eq 'read_codepoint' \
49+
"$(printf 'あZ' | "$BIN" -e '(pr-str [(.read *in*) (.read *in*) (.read *in*)])' | tail -1)" \
50+
'"[12354 90 -1]"'
51+
52+
echo "ALL phase16_stdin_read_line PASS"

‎test/run_all.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ run_step "e2e_phase16_fs_jail" "bash test/e2e/phase16_fs_jail.sh"
618618
run_step "e2e_phase16_java_io_file" "bash test/e2e/phase16_java_io_file.sh"
619619
run_step "e2e_phase16_clojure_java_io" "bash test/e2e/phase16_clojure_java_io.sh"
620620
run_step "e2e_phase16_host_stream" "bash test/e2e/phase16_host_stream.sh"
621+
run_step "e2e_phase16_stdin_read_line" "bash test/e2e/phase16_stdin_read_line.sh"
621622
run_step "e2e_phase16_clojure_java_io_streams" "bash test/e2e/phase16_clojure_java_io_streams.sh"
622623
run_step "e2e_phase16_clojure_java_io_copy" "bash test/e2e/phase16_clojure_java_io_copy.sh"
623624
run_step "e2e_phase16_cljw_json_fs" "bash test/e2e/phase16_cljw_json_fs.sh"

0 commit comments

Comments
 (0)