Files
bun.sh/src/shell/IO.zig
Jarred Sumner 6bafe2602e Fix Windows shell crash with && operator and external commands (#22651)
## What does this PR do?

Fixes https://github.com/oven-sh/bun/issues/22650
Fixes https://github.com/oven-sh/bun/issues/22615
Fixes https://github.com/oven-sh/bun/issues/22603
Fixes https://github.com/oven-sh/bun/issues/22602

Fixes a crash that occurred when running shell commands through `bun
run` (package.json scripts) on Windows that use the `&&` operator
followed by an external command.

### The Problem

The minimal reproduction was:
```bash
bun exec 'echo && node --version'
```

This would crash with: `panic(main thread): attempt to use null value`

### Root Causes

Two issues were causing the crash:

1. **Missing top_level_dir**: When `runPackageScriptForeground` creates
a MiniEventLoop for running package scripts, it wasn't setting the
`top_level_dir` field. This caused a null pointer dereference when the
shell tried to access it.

2. **MovableIfWindowsFd handling**: After PR #21800 introduced
`MovableIfWindowsFd` to handle file descriptor ownership on Windows, the
`IOWriter.fd` could be moved to libuv, leaving it null. When the shell
tried to spawn an external command after a `&&` operator, it would crash
trying to access this null fd.

### The Fix

1. Set `mini.top_level_dir = cwd` after initializing the MiniEventLoop
in `run_command.zig`
2. In `IO.zig`, when the fd has been moved to libuv (is null), use
`.inherit` for stdio instead of trying to pass the null fd

### How did you verify your code works?

- Added a regression test that reproduces the issue
- Verified the test fails without the fix and passes with it
- Tested the minimal reproduction command directly
- The fix correctly allows both commands in the `&&` chain to execute

```bash
# Before fix: crashes
> bun exec 'echo test && node --version'
panic(main thread): attempt to use null value

# After fix: works correctly
> bun exec 'echo test && node --version'
test
v22.4.1
```
<sub>
also probably fixes #22615 and fixes #22603 and fixes #22602
</sub>

---------

Co-authored-by: Zack Radisic <zack@theradisic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-14 04:14:48 -07:00

176 lines
4.9 KiB
Zig

//! This struct carries around information for a state node's stdin/stdout/stderr.
pub const IO = @This();
stdin: InKind,
stdout: OutKind,
stderr: OutKind,
pub fn format(this: IO, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
try writer.print("stdin: {}\nstdout: {}\nstderr: {}", .{ this.stdin, this.stdout, this.stderr });
}
pub fn deinit(this: *IO) void {
this.stdin.close();
this.stdout.close();
this.stderr.close();
}
pub fn copy(this: *IO) IO {
_ = this.ref();
return this.*;
}
pub fn ref(this: *IO) *IO {
_ = this.stdin.ref();
_ = this.stdout.ref();
_ = this.stderr.ref();
return this;
}
pub fn deref(this: *IO) void {
this.stdin.deref();
this.stdout.deref();
this.stderr.deref();
}
pub const InKind = union(enum) {
fd: *Interpreter.IOReader,
ignore,
pub fn format(this: InKind, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
switch (this) {
.fd => try writer.print("fd: {}", .{this.fd.fd}),
.ignore => try writer.print("ignore", .{}),
}
}
pub fn ref(this: InKind) InKind {
switch (this) {
.fd => this.fd.ref(),
.ignore => {},
}
return this;
}
pub fn deref(this: InKind) void {
switch (this) {
.fd => this.fd.deref(),
.ignore => {},
}
}
pub fn close(this: InKind) void {
switch (this) {
.fd => this.fd.deref(),
.ignore => {},
}
}
pub fn to_subproc_stdio(this: InKind, stdio: *bun.shell.subproc.Stdio) void {
switch (this) {
.fd => {
stdio.* = .{ .fd = this.fd.fd };
},
.ignore => {
stdio.* = .ignore;
},
}
}
};
pub const OutKind = union(enum) {
/// Write/Read to/from file descriptor
/// If `captured` is non-null, it will write to std{out,err} and also buffer it.
/// The pointer points to the `buffered_stdout`/`buffered_stdin` fields
/// in the Interpreter struct
fd: struct { writer: *Interpreter.IOWriter, captured: ?*bun.ByteList = null },
/// Buffers the output (handled in Cmd.BufferedIoClosed.close())
///
/// This is set when the shell is called with `.quiet()`
pipe,
/// Discards output
ignore,
// fn dupeForSubshell(this: *ShellExecEnv,
pub fn format(this: OutKind, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
switch (this) {
.fd => try writer.print("fd: {}", .{this.fd.writer.fd}),
.pipe => try writer.print("pipe", .{}),
.ignore => try writer.print("ignore", .{}),
}
}
pub fn ref(this: @This()) @This() {
switch (this) {
.fd => {
this.fd.writer.ref();
},
else => {},
}
return this;
}
pub fn deref(this: @This()) void {
this.close();
}
pub fn enqueueFmtBltn(
this: *@This(),
ptr: anytype,
comptime kind: ?Interpreter.Builtin.Kind,
comptime fmt_: []const u8,
args: anytype,
_: OutputNeedsIOSafeGuard,
) void {
this.fd.writer.enqueueFmtBltn(ptr, this.fd.captured, kind, fmt_, args);
}
fn close(this: OutKind) void {
switch (this) {
.fd => {
this.fd.writer.deref();
},
else => {},
}
}
fn to_subproc_stdio(this: OutKind, shellio: *?*shell.IOWriter) bun.shell.subproc.Stdio {
return switch (this) {
.fd => |val| brk: {
shellio.* = val.writer.refSelf();
break :brk if (val.captured) |cap| .{
.capture = .{
.buf = cap,
},
} else if (val.writer.fd.get()) |fd| .{
// We have a valid fd that hasn't been moved to libuv
.fd = fd,
} else .{
// On Windows, the fd might have been moved to libuv
// In this case, the subprocess should inherit the stdio
// since libuv is already managing it
.inherit = {},
};
},
.pipe => .pipe,
.ignore => .ignore,
};
}
};
pub fn to_subproc_stdio(this: IO, stdio: *[3]bun.shell.subproc.Stdio, shellio: *shell.subproc.ShellIO) void {
this.stdin.to_subproc_stdio(&stdio[0]);
stdio[stdout_no] = this.stdout.to_subproc_stdio(&shellio.stdout);
stdio[stderr_no] = this.stderr.to_subproc_stdio(&shellio.stderr);
}
const bun = @import("bun");
const std = @import("std");
const shell = bun.shell;
const Interpreter = bun.shell.Interpreter;
const OutputNeedsIOSafeGuard = bun.shell.interpret.OutputNeedsIOSafeGuard;
const stderr_no = bun.shell.interpret.stderr_no;
const stdout_no = bun.shell.interpret.stdout_no;