Files
bun.sh/test/js/bun/shell/file-io.test.ts
Dylan Conway c1acb0b9a4 fix(shell): prevent double-close of fd when using &> redirect with builtins (#25568)
## Summary

- Fix double-close of file descriptor when using `&>` redirect with
shell builtin commands
- Add `dupeRef()` helper for cleaner reference counting semantics
- Add tests for `&>` and `&>>` redirects with builtins

## Test plan

- [x] Added tests in `test/js/bun/shell/file-io.test.ts` that reproduce
the bug
- [x] All file-io tests pass

## The Bug

When using `&>` to redirect both stdout and stderr to the same file with
a shell builtin command (e.g., `pwd &> file.txt`), the code was creating
two separate `IOWriter` instances that shared the same file descriptor.
When both `IOWriter`s were destroyed, they both tried to close the same
fd, causing an `EBADF` (bad file descriptor) error.

```javascript
import { $ } from "bun";
await $`pwd &> output.txt`; // Would crash with EBADF
```

## The Fix

1. Share a single `IOWriter` between stdout and stderr when both are
redirected to the same file, with proper reference counting
2. Rename `refSelf` to `dupeRef` for clarity across `IOReader`,
`IOWriter`, `CowFd`, and add it to `Blob` for consistency
3. Fix the `Body.Value` blob case to also properly reference count when
the same blob is assigned to multiple outputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Latest model <noreply@anthropic.com>
2025-12-17 18:33:53 -08:00

159 lines
6.1 KiB
TypeScript

import { describe } from "bun:test";
import { createTestBuilder } from "./test_builder";
const TestBuilder = createTestBuilder(import.meta.path);
describe("IOWriter file output redirection", () => {
describe("basic file redirection", () => {
TestBuilder.command`echo "hello world" > output.txt`
.exitCode(0)
.fileEquals("output.txt", "hello world\n")
.runAsTest("simple echo to file");
TestBuilder.command`echo -n "" > empty.txt`
.exitCode(0)
.fileEquals("empty.txt", "")
.runAsTest("empty output to file");
TestBuilder.command`echo "" > zero.txt`
.exitCode(0)
.fileEquals("zero.txt", "\n")
.runAsTest("zero-length write should trigger onIOWriterChunk callback");
});
describe("drainBufferedData edge cases", () => {
TestBuilder.command`echo -n ${"x".repeat(1024 * 10)} > large.txt`
.exitCode(0)
.fileEquals("large.txt", "x".repeat(1024 * 10))
.runAsTest("large single write");
TestBuilder.command`mkdir -p subdir && echo "test" > subdir/file.txt`
.exitCode(0)
.fileEquals("subdir/file.txt", "test\n")
.runAsTest("write to subdirectory");
});
describe("file system error conditions", () => {
TestBuilder.command`echo "should fail" > /dev/null/invalid/path`
.exitCode(1)
.stderr_contains("directory: /dev/null/invalid/path")
.runAsTest("write to invalid path should fail");
TestBuilder.command`echo "should fail" > /nonexistent/file.txt`
.exitCode(1)
.stderr_contains("No such file or directory")
.runAsTest("write to non-existent directory should fail");
});
describe("special file types", () => {
TestBuilder.command`echo "disappear" > /dev/null`.exitCode(0).stdout("").runAsTest("write to /dev/null");
});
describe("writer queue and bump behavior", () => {
TestBuilder.command`echo "single" > single_writer.txt`
.exitCode(0)
.fileEquals("single_writer.txt", "single\n")
.runAsTest("single writer completion and cleanup");
TestBuilder.command`echo "robust test" > robust.txt`
.exitCode(0)
.fileEquals("robust.txt", "robust test\n")
.runAsTest("writer marked as dead during write");
TestBuilder.command`echo "captured content" > capture.txt`
.exitCode(0)
.fileEquals("capture.txt", "captured content\n")
.stdout("")
.runAsTest("bytelist capture during file write");
});
describe("error handling and unreachable paths", () => {
TestBuilder.command`echo -n ${"A".repeat(2 * 1024)} > atomic.txt`
.exitCode(0)
.fileEquals("atomic.txt", "A".repeat(2 * 1024))
.runAsTest("attempt to trigger partial write panic");
TestBuilder.command`echo "synchronous" > sync_write.txt`
.exitCode(0)
.fileEquals("sync_write.txt", "synchronous\n")
.runAsTest("EAGAIN should never occur for files");
TestBuilder.command`echo "error test" > nonexistent_dir/file.txt`
.exitCode(1)
.stderr_contains("No such file or directory")
.runAsTest("write error propagation");
});
describe("file permissions and creation", () => {
TestBuilder.command`echo "new file" > new_file.txt`
.exitCode(0)
.fileEquals("new_file.txt", "new file\n")
.runAsTest("file creation with default permissions");
TestBuilder.command`echo "original" > overwrite.txt && echo "short" > overwrite.txt`
.exitCode(0)
.fileEquals("overwrite.txt", "short\n")
.runAsTest("overwrite existing file");
TestBuilder.command`echo "line1" > append.txt && echo "line2" >> append.txt && echo "line3" >> append.txt`
.exitCode(0)
.fileEquals("append.txt", "line1\nline2\nline3\n")
.runAsTest("append to existing file");
});
// describe("concurrent operations", () => {
// TestBuilder.command`echo "content 0" > concurrent_0.txt & echo "content 1" > concurrent_1.txt & echo "content 2" > concurrent_2.txt & wait`
// .exitCode(0)
// .fileEquals("concurrent_0.txt", "content 0\n")
// .fileEquals("concurrent_1.txt", "content 1\n")
// .fileEquals("concurrent_2.txt", "content 2\n")
// .runAsTest("concurrent writes to different files");
// TestBuilder.command`echo "iteration 0" > rapid.txt && echo "iteration 1" > rapid.txt && echo "iteration 2" > rapid.txt`
// .exitCode(0)
// .fileEquals("rapid.txt", "iteration 2\n")
// .runAsTest("rapid sequential writes to same file");
// });
describe("additional TestBuilder integration", () => {
TestBuilder.command`echo "builder test" > output.txt`
.exitCode(0)
.fileEquals("output.txt", "builder test\n")
.runAsTest("basic file output");
TestBuilder.command`printf "no newline" > no_newline.txt`
.exitCode(0)
.fileEquals("no_newline.txt", "no newline")
.runAsTest("output without trailing newline");
TestBuilder.command`echo "first" > multi.txt && echo "second" >> multi.txt`
.exitCode(0)
.fileEquals("multi.txt", "first\nsecond\n")
.runAsTest("write then append");
TestBuilder.command`echo "test with spaces in filename" > "file with spaces.txt"`
.exitCode(0)
.fileEquals("file with spaces.txt", "test with spaces in filename\n")
.runAsTest("write to file with spaces in name");
TestBuilder.command`echo "pipe test" | cat > pipe_output.txt`
.exitCode(0)
.fileEquals("pipe_output.txt", "pipe test\n")
.runAsTest("pipe with file redirection");
});
describe("&> redirect (stdout and stderr to same file)", () => {
// This test verifies the fix for the bug where using &> with a builtin
// command caused the same file descriptor to be closed twice, resulting
// in an EBADF error. The issue was that two separate IOWriter instances
// were created for the same fd when both stdout and stderr were redirected.
TestBuilder.command`pwd &> pwd_output.txt`.exitCode(0).runAsTest("builtin pwd with &> redirect");
TestBuilder.command`echo "hello" &> echo_output.txt`
.exitCode(0)
.fileEquals("echo_output.txt", "hello\n")
.runAsTest("builtin echo with &> redirect");
TestBuilder.command`pwd &>> append_output.txt`.exitCode(0).runAsTest("builtin pwd with &>> append redirect");
});
});