Skip to content

Commit 638d663

Browse files
a0preethamclaude
andcommitted
refactor(interpreter): replace groupStdin string with StdinCursor class
Replaces the `groupStdin?: string` snapshot pattern in IOState with a `StdinCursor` object that tracks a position index into the original content. The cursor is shared by reference across commands in the same while-loop body, so any command that consumes stdin advances the position in-place without needing explicit snapshot/restore gymnastics. Key changes: - New `StdinCursor` class with `remaining`, `advance(n)`, `readAll()` - `read` and `mapfile` builtins call `advance` / `readAll` on the cursor - `file-reader.ts` calls `ctx.stdinCursor?.readAll()` so cat, grep, head, awk, sed etc. inside while loops all advance position correctly - `pipeline-execution.ts` simplified: save ref before pipeline, null it for non-first commands, restore after — the shared object carries any position advancement made by the first command - `executeGroup`, `executeSubshell`, `executeUserScript`, `eval` all install a fresh cursor only when they have their own stdin source; otherwise they share the outer cursor - `Bash.ts` constructs a StdinCursor from the top-level stdin option instead of passing a raw string Fixes the case where `cat | tr` (or any file-reader-based command) was used as the first command in a while-loop pipeline body and failed to advance the loop's stdin position. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyEbpGVknBW94w6XpvP462
1 parent 1ec5eec commit 638d663

17 files changed

Lines changed: 497 additions & 59 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"just-bash": patch
3+
---
4+
5+
interpreter: fix pipelines inside while-loop bodies eating loop stdin
6+
7+
Commands inside a `while` loop body that were the first stage of a pipeline
8+
(e.g. `cat | tr a-z A-Z`) or that read stdin directly (e.g. `grep pattern`)
9+
were silently consuming the loop's stdin without advancing the shared position,
10+
causing subsequent `read` calls in the loop condition to see stale or
11+
already-consumed input.
12+
13+
Replaces the `groupStdin?: string` snapshot in `IOState` with a `StdinCursor`
14+
class that tracks a byte offset into the original content. The cursor is shared
15+
by reference across all commands in a loop body, so any command that consumes
16+
stdin advances the position in-place. The loop's `read` builtin then resumes
17+
from the correct position on the next iteration without needing
18+
snapshot/restore logic.
19+
20+
**Before** — a pipeline in the while body would silently reset stdin:
21+
22+
```bash
23+
# Expected: A B C — Actual (before fix): infinite loop / wrong lines
24+
while IFS= read -r line; do
25+
echo "$line" | cat
26+
done <<'EOF'
27+
A
28+
B
29+
C
30+
EOF
31+
```
32+
33+
**After** — all commands share the cursor and advance it atomically:
34+
35+
```bash
36+
# Works correctly in all body patterns
37+
while IFS= read -r line; do echo "$line" | cat; done # pipeline body
38+
while IFS= read -r line; do cat | tr a-z A-Z; done # file-reader first
39+
while IFS= read -r line; do grep "pat"; done # direct stdin read
40+
```

packages/just-bash/src/Bash.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
SecurityViolationError,
6363
} from "./security/defense-in-depth-box.js";
6464
import type { DefenseInDepthConfig } from "./security/types.js";
65+
import { StdinCursor } from "./stdin-cursor.js";
6566
import { serialize } from "./transform/serialize.js";
6667
import type {
6768
BashTransformResult,
@@ -648,7 +649,11 @@ export class Bash {
648649
// UTF-8 bytes. Callers that already prepared a byte buffer (e.g.
649650
// `Buffer.from(buf).toString("latin1")`) opt into raw passthrough
650651
// via `stdinKind: "bytes"`.
651-
groupStdin: encodeStdinForPipeline(options?.stdin, options?.stdinKind),
652+
stdinCursor: options?.stdin
653+
? new StdinCursor(
654+
encodeStdinForPipeline(options.stdin, options.stdinKind) as string,
655+
)
656+
: undefined,
652657
// Cooperative cancellation signal (used by timeout command)
653658
signal: options?.signal,
654659
// Extra arguments injected directly into first command's arg list

packages/just-bash/src/commands/grep/grep.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,9 @@ export const grepCommand: Command = {
229229
// If no files and stdin is provided (including empty string), read from
230230
// stdin. grep runs regex over text — decode bytes to UTF-8 so multibyte
231231
// codepoints match `.` / character classes correctly.
232+
// Advance the cursor so while-loop iterations know stdin was consumed.
232233
if (files.length === 0 && ctx.stdin !== undefined) {
234+
ctx.stdinCursor?.readAll();
233235
const result = searchContent(decodeBytesToUtf8(ctx.stdin), regex, {
234236
invertMatch,
235237
showLineNumbers,

packages/just-bash/src/interpreter/builtin-dispatch.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -412,17 +412,15 @@ export async function executeExternalCommand(
412412
ctx.state.hashTable.set(commandName, cmdPath);
413413
}
414414

415-
// Use groupStdin as fallback if no stdin from redirections/pipeline —
416-
// needed for commands inside groups/functions that receive stdin via
417-
// heredoc. The pipeline glue (pipeline-execution.ts) and the
418-
// stdin-source sites (heredoc, here-string, `< file`, options.stdin)
419-
// are responsible for handing us a latin1-shaped byte buffer; we just
420-
// brand it. Commands that decode their input internally (sed, jq,
421-
// ...) return text via `textOutput()`, and the pipe / redirect layer
422-
// converts to bytes on their behalf.
423-
const effectiveStdin = unsafeBytesFromLatin1(
424-
stdin || ctx.state.groupStdin || "",
425-
);
415+
// Use the cursor's remaining content as fallback when no pipeline stdin.
416+
// The cursor is shared by reference: commands that consume stdin via
417+
// file-reader (cat, grep, head, …) call cursor.readAll() which advances
418+
// the position, so subsequent loop iterations start from the right place.
419+
// Only pass the cursor when stdin is empty (pipeline commands with explicit
420+
// stdin must not fall back to the loop's cursor).
421+
const cursorContent = !stdin ? ctx.state.stdinCursor?.remaining : undefined;
422+
const effectiveStdin = unsafeBytesFromLatin1(stdin || cursorContent || "");
423+
const stdinCursor = !stdin ? ctx.state.stdinCursor : undefined;
426424

427425
// Build exported environment for commands that need it (printenv, env, etc.)
428426
// Most builtins need access to the full env to modify state
@@ -434,6 +432,7 @@ export async function executeExternalCommand(
434432
env: ctx.state.env,
435433
exportedEnv,
436434
stdin: effectiveStdin,
435+
stdinCursor,
437436
limits: ctx.limits,
438437
exec: ctx.execFn,
439438
fetch: ctx.fetch,

packages/just-bash/src/interpreter/builtins/eval.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { type ParseException, parse } from "../../parser/parser.js";
9+
import { StdinCursor } from "../../stdin-cursor.js";
910
import type { ExecResult } from "../../types.js";
1011
import {
1112
BreakError,
@@ -50,12 +51,12 @@ export async function handleEval(
5051
return OK;
5152
}
5253

53-
// Save and set groupStdin for piped eval commands
54-
// This allows stdin from the pipeline to flow to commands within eval
55-
const savedGroupStdin = ctx.state.groupStdin;
56-
const effectiveStdin = stdin ?? ctx.state.groupStdin;
57-
if (effectiveStdin !== undefined) {
58-
ctx.state.groupStdin = effectiveStdin;
54+
// If eval received stdin from a pipeline, install a fresh cursor for it so
55+
// read commands inside eval consume from that input. If no pipeline stdin,
56+
// eval shares the outer cursor — reads inside eval advance it normally.
57+
const savedCursor = ctx.state.stdinCursor;
58+
if (stdin) {
59+
ctx.state.stdinCursor = new StdinCursor(stdin);
5960
}
6061

6162
try {
@@ -77,7 +78,8 @@ export async function handleEval(
7778
}
7879
throw error;
7980
} finally {
80-
// Restore groupStdin
81-
ctx.state.groupStdin = savedGroupStdin;
81+
if (stdin) {
82+
ctx.state.stdinCursor = savedCursor;
83+
}
8284
}
8385
}

packages/just-bash/src/interpreter/builtins/mapfile.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ export function handleMapfile(
6262
}
6363
}
6464

65-
// Use stdin from parameter, or fall back to groupStdin
65+
// Use stdin from parameter, or fall back to cursor's remaining content.
6666
let effectiveStdin = stdin;
67-
if (!effectiveStdin && ctx.state.groupStdin !== undefined) {
68-
effectiveStdin = ctx.state.groupStdin;
67+
if (!effectiveStdin && ctx.state.stdinCursor !== undefined) {
68+
effectiveStdin = ctx.state.stdinCursor.remaining;
6969
}
7070

7171
// Split input by delimiter
@@ -162,9 +162,9 @@ export function handleMapfile(
162162
String(Math.max(existingLength, newEndIndex)),
163163
);
164164

165-
// Consume from groupStdin if we used it
166-
if (ctx.state.groupStdin !== undefined && !stdin) {
167-
ctx.state.groupStdin = "";
165+
// mapfile reads all stdin — exhaust the cursor so the loop advances.
166+
if (ctx.state.stdinCursor !== undefined && !stdin) {
167+
ctx.state.stdinCursor.readAll();
168168
}
169169

170170
return result("", "", 0);

packages/just-bash/src/interpreter/builtins/read.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export function handleRead(
263263
return result("", "", 1);
264264
}
265265

266-
// Use stdin from parameter, or fall back to groupStdin (for piped groups/while loops)
266+
// Use stdin from parameter, or fall back to stdinCursor (for piped groups/while loops)
267267
// If -u is specified, use the file descriptor content instead
268268
let effectiveStdin = stdin;
269269

@@ -274,8 +274,8 @@ export function handleRead(
274274
} else {
275275
effectiveStdin = "";
276276
}
277-
} else if (!effectiveStdin && ctx.state.groupStdin !== undefined) {
278-
effectiveStdin = ctx.state.groupStdin;
277+
} else if (!effectiveStdin && ctx.state.stdinCursor !== undefined) {
278+
effectiveStdin = ctx.state.stdinCursor.remaining;
279279
}
280280

281281
// Handle -d '' (empty delimiter) - reads until NUL byte
@@ -308,8 +308,8 @@ export function handleRead(
308308
);
309309
}
310310
}
311-
} else if (ctx.state.groupStdin !== undefined && !stdin) {
312-
ctx.state.groupStdin = effectiveStdin.substring(bytesConsumed);
311+
} else if (ctx.state.stdinCursor !== undefined && !stdin) {
312+
ctx.state.stdinCursor.advance(bytesConsumed);
313313
}
314314
};
315315

packages/just-bash/src/interpreter/control-flow.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
WhileNode,
2222
WordNode,
2323
} from "../ast/types.js";
24+
import { StdinCursor } from "../stdin-cursor.js";
2425
import type { ExecResult } from "../types.js";
2526
import { evaluateArithmetic } from "./arithmetic.js";
2627
import { matchPattern } from "./conditionals.js";
@@ -66,6 +67,7 @@ export async function executeIf(
6667
export async function executeFor(
6768
ctx: InterpreterContext,
6869
node: ForNode,
70+
stdin = "",
6971
): Promise<ExecResult> {
7072
// Pre-open output redirects to truncate files BEFORE expanding words
7173
// This matches bash behavior where redirect files are opened before
@@ -85,6 +87,41 @@ export async function executeFor(
8587
return failure(`bash: \`${node.variable}': not a valid identifier\n`);
8688
}
8789

90+
// Process stdin redirections so body commands (e.g. read) can consume them.
91+
let effectiveStdin = stdin;
92+
for (const redir of node.redirections) {
93+
if (
94+
(redir.operator === "<<" || redir.operator === "<<-") &&
95+
redir.target.type === "HereDoc"
96+
) {
97+
const hereDoc = redir.target as HereDocNode;
98+
let content = await expandWord(ctx, hereDoc.content);
99+
if (hereDoc.stripTabs) {
100+
content = content
101+
.split("\n")
102+
.map((line) => line.replace(/^\t+/, ""))
103+
.join("\n");
104+
}
105+
effectiveStdin = content;
106+
} else if (redir.operator === "<<<" && redir.target.type === "Word") {
107+
effectiveStdin = `${await expandWord(ctx, redir.target as WordNode)}\n`;
108+
} else if (redir.operator === "<" && redir.target.type === "Word") {
109+
try {
110+
const target = await expandWord(ctx, redir.target as WordNode);
111+
const filePath = ctx.fs.resolvePath(ctx.state.cwd, target);
112+
effectiveStdin = await ctx.fs.readFile(filePath);
113+
} catch {
114+
const target = await expandWord(ctx, redir.target as WordNode);
115+
return failure(`bash: ${target}: No such file or directory\n`);
116+
}
117+
}
118+
}
119+
120+
const savedCursor = ctx.state.stdinCursor;
121+
if (effectiveStdin) {
122+
ctx.state.stdinCursor = new StdinCursor(effectiveStdin);
123+
}
124+
88125
let words: string[] = [];
89126
if (node.words === null) {
90127
words = (ctx.state.env.get("@") || "").split(" ").filter(Boolean);
@@ -148,6 +185,7 @@ export async function executeFor(
148185
}
149186
} finally {
150187
ctx.state.loopDepth--;
188+
ctx.state.stdinCursor = savedCursor;
151189
}
152190

153191
// Note: In bash, the loop variable persists after the loop with its last value
@@ -295,10 +333,10 @@ export async function executeWhile(
295333
}
296334
}
297335

298-
// Save and set groupStdin for piped while loops
299-
const savedGroupStdin = ctx.state.groupStdin;
336+
// Install a fresh cursor for this loop's stdin, saving any outer cursor.
337+
const savedCursor = ctx.state.stdinCursor;
300338
if (effectiveStdin) {
301-
ctx.state.groupStdin = effectiveStdin;
339+
ctx.state.stdinCursor = new StdinCursor(effectiveStdin);
302340
}
303341

304342
ctx.state.loopDepth++;
@@ -390,7 +428,7 @@ export async function executeWhile(
390428
}
391429
} finally {
392430
ctx.state.loopDepth--;
393-
ctx.state.groupStdin = savedGroupStdin;
431+
ctx.state.stdinCursor = savedCursor;
394432
}
395433

396434
return result(stdout, stderr, exitCode);
@@ -399,12 +437,47 @@ export async function executeWhile(
399437
export async function executeUntil(
400438
ctx: InterpreterContext,
401439
node: UntilNode,
440+
stdin = "",
402441
): Promise<ExecResult> {
403442
let stdout = "";
404443
let stderr = "";
405444
let exitCode = 0;
406445
let iterations = 0;
407446

447+
let effectiveStdin = stdin;
448+
for (const redir of node.redirections) {
449+
if (
450+
(redir.operator === "<<" || redir.operator === "<<-") &&
451+
redir.target.type === "HereDoc"
452+
) {
453+
const hereDoc = redir.target as HereDocNode;
454+
let content = await expandWord(ctx, hereDoc.content);
455+
if (hereDoc.stripTabs) {
456+
content = content
457+
.split("\n")
458+
.map((line) => line.replace(/^\t+/, ""))
459+
.join("\n");
460+
}
461+
effectiveStdin = content;
462+
} else if (redir.operator === "<<<" && redir.target.type === "Word") {
463+
effectiveStdin = `${await expandWord(ctx, redir.target as WordNode)}\n`;
464+
} else if (redir.operator === "<" && redir.target.type === "Word") {
465+
try {
466+
const target = await expandWord(ctx, redir.target as WordNode);
467+
const filePath = ctx.fs.resolvePath(ctx.state.cwd, target);
468+
effectiveStdin = await ctx.fs.readFile(filePath);
469+
} catch {
470+
const target = await expandWord(ctx, redir.target as WordNode);
471+
return failure(`bash: ${target}: No such file or directory\n`);
472+
}
473+
}
474+
}
475+
476+
const savedCursor = ctx.state.stdinCursor;
477+
if (effectiveStdin) {
478+
ctx.state.stdinCursor = new StdinCursor(effectiveStdin);
479+
}
480+
408481
ctx.state.loopDepth++;
409482
try {
410483
while (true) {
@@ -451,6 +524,7 @@ export async function executeUntil(
451524
}
452525
} finally {
453526
ctx.state.loopDepth--;
527+
ctx.state.stdinCursor = savedCursor;
454528
}
455529

456530
return result(stdout, stderr, exitCode);

packages/just-bash/src/interpreter/interpreter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,13 +502,13 @@ export class Interpreter {
502502
case "If":
503503
return executeIf(this.ctx, node);
504504
case "For":
505-
return executeFor(this.ctx, node);
505+
return executeFor(this.ctx, node, stdin);
506506
case "CStyleFor":
507507
return executeCStyleFor(this.ctx, node);
508508
case "While":
509509
return executeWhile(this.ctx, node, stdin);
510510
case "Until":
511-
return executeUntil(this.ctx, node);
511+
return executeUntil(this.ctx, node, stdin);
512512
case "Case":
513513
return executeCase(this.ctx, node);
514514
case "Subshell":

0 commit comments

Comments
 (0)