Skip to content

fix(repl): use external clipboard tools for .copy command#27672

Open
robobun wants to merge 4 commits intomainfrom
claude/fix-repl-copy-external-clipboard-27671
Open

fix(repl): use external clipboard tools for .copy command#27672
robobun wants to merge 4 commits intomainfrom
claude/fix-repl-copy-external-clipboard-27671

Conversation

@robobun
Copy link
Collaborator

@robobun robobun commented Mar 1, 2026

Summary

  • The .copy command in the REPL previously relied exclusively on OSC 52 escape sequences to copy text to the clipboard
  • OSC 52 is not supported by VTE-based terminals (XFCE4 Terminal, GNOME Terminal, and others), causing .copy to silently fail
  • Now tries external clipboard tools first (xclip, xsel, wl-copy on Linux; pbcopy on macOS), then falls back to OSC 52 for SSH sessions and terminals that support it

Test plan

  • Added regression test (test/regression/issue/27671.test.ts) with fake xclip that verifies text is piped correctly
  • Tests verify numeric values, string values (copied without quotes), and OSC 52 fallback when no tools are available
  • All 105 existing REPL tests continue to pass
  • Verified regression test fails with system bun (pre-fix) and passes with debug build (post-fix)

Closes #27671

🤖 Generated with Claude Code

The .copy command previously relied exclusively on OSC 52 escape sequences,
which are not supported by VTE-based terminals (XFCE4 Terminal, GNOME Terminal).
Now tries external clipboard tools first (xclip, xsel, wl-copy on Linux;
pbcopy on macOS), then falls back to OSC 52 for SSH and compatible terminals.

Closes #27671

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added the claude label Mar 1, 2026
@robobun
Copy link
Collaborator Author

robobun commented Mar 1, 2026

Updated 8:53 AM PT - Mar 1st, 2026

❌ Your commit 283427bd has 3 failures in Build #38496 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 27672

That installs a local version of the PR into your bun-27672 executable, so you can run:

bun-27672 --bun
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

Walkthrough

Reworks REPL .copy to strip ANSI sequences, prefer external clipboard tools (pbcopy, xclip, xsel, wl-copy) and only use OSC 52 as a fallback. Adds internal helpers for external invocation and ANSI stripping, and adds regression tests covering both external-tool and OSC 52 paths.

Changes

Cohort / File(s) Summary
Clipboard copy implementation
src/repl.zig
Reworked .copy flow: added stripAnsiForClipboard, added copyToClipboardExternal (tries pbcopy, xclip, xsel, wl-copy), adjusted copyToClipboardOSC52 to accept already-stripped text, and updated copy message logic. All helpers are internal; no public API signature changes.
Regression tests for issue 27671
test/regression/issue/27671.test.ts
New tests that simulate external clipboard tools via a fake utility, manipulate PATH, verify .copy pipes cleaned text to external tools (including string values without quotes), and validate OSC 52 fallback when no external tools are available.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: implementing external clipboard tool support for the .copy command.
Description check ✅ Passed The description covers both required sections: what the PR does (replacing OSC 52 with external tools) and how it was verified (regression tests, existing tests pass).
Linked Issues check ✅ Passed The PR fully addresses issue #27671 by implementing external clipboard tool support for VTE-based terminals, with regression test coverage for the specific failure scenario.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the .copy command clipboard functionality; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 96-98: The test currently only checks generic success and can pass
when an external clipboard tool exists; modify the test around the runRepl
invocation (the call using commands ".copy 42" and ".exit" that returns stdout
and exitCode) to simulate absence of any external clipboard utility (e.g., by
sanitizing PATH or ensuring no helper binaries are available) and add an
assertion that the raw OSC 52 sequence (the OSC 52 CSI prefix like "\x1b]52;c;")
appears in stdout to confirm the fallback path is used; apply the same
strengthening to the similar assertions in the later block (lines covering the
second runRepl case mentioned around 100-107).
- Around line 37-55: The test always creates a fake "xclip" helper but on macOS
the REPL uses "pbcopy", causing the fake helper to be ignored; update the test
setup in the block that creates fakeClipboard/clipOutputFile and the subsequent
runRepl invocation so it is platform-aware: detect process.platform (or
isDarwin) and create the appropriate fake binary name ("pbcopy" on Darwin,
"xclip" elsewhere) or create both helpers, write the same shell wrapper that
writes stdin to clipOutputFile, make them executable (chmod), and ensure the
PATH passed to runRepl includes the temp dir so the REPL will invoke the fake
helper referenced by the .copy command used in the test.
- Line 74: The second call to Bun.spawnSync({ cmd: ["chmod", "+x",
fakeClipboard] }) lacks an exit-code assertion, so capture its result (e.g.,
const { exitCode: chmodExit } = Bun.spawnSync(...)) and add an assertion
(expect(chmodExit).toBe(0)); modify the invocation near the second occurrence of
Bun.spawnSync and fakeClipboard in the test so the test fails at the chmod step
rather than later in runRepl.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2cae6c3 and a7a8a57.

📒 Files selected for processing (2)
  • src/repl.zig
  • test/regression/issue/27671.test.ts
- Use platform-appropriate fake clipboard tool name (pbcopy on macOS,
  xclip on Linux)
- Assert chmod exit code in all test cases
- Verify OSC 52 escape sequence appears in stdout when no external
  clipboard tools are available

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 45-53: The test duplicates fake clipboard setup (creating
clipOutputFile, writing the shell script to fakeClipboard, and chmod) in two
places; extract that logic into a single helper (e.g., createFakeClipboard or
setupFakeClipboard) and replace both blocks with calls to it. The helper should
accept dir and fakeClipboardName (and optionally clipOutputFile path) and
perform the Bun.write and Bun.spawnSync chmod steps, returning any paths or
results needed by tests so existing references to clipOutputFile, fakeClipboard,
and fakeClipboardName work unchanged.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a7a8a57 and 62ae8ab.

📒 Files selected for processing (1)
  • test/regression/issue/27671.test.ts
Comment on lines 45 to 53
const clipOutputFile = `${dir}/clip-output.txt`;

// Create a fake clipboard tool that saves stdin to a file
const fakeClipboard = `${dir}/${fakeClipboardName}`;
await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
const { exitCode: chmodExit } = Bun.spawnSync({
cmd: ["chmod", "+x", fakeClipboard],
});
expect(chmodExit).toBe(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Extract duplicated fake-clipboard setup into one helper.

The two setup blocks are identical and should be centralized to avoid drift.

♻️ Suggested refactor
+async function setupFakeClipboard(dir: string, clipOutputFile: string): Promise<void> {
+  const fakeClipboard = `${dir}/${fakeClipboardName}`;
+  await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
+  const { exitCode: chmodExit } = Bun.spawnSync({
+    cmd: ["chmod", "+x", fakeClipboard],
+  });
+  expect(chmodExit).toBe(0);
+}
+
 describe("issue `#27671` - .copy with external clipboard tools", () => {
   test.skipIf(isWindows)(".copy pipes text to external clipboard tool", async () => {
     using dir = tempDir("repl-copy-test", {});
 
     const clipOutputFile = `${dir}/clip-output.txt`;
-
-    // Create a fake clipboard tool that saves stdin to a file
-    const fakeClipboard = `${dir}/${fakeClipboardName}`;
-    await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
-    const { exitCode: chmodExit } = Bun.spawnSync({
-      cmd: ["chmod", "+x", fakeClipboard],
-    });
-    expect(chmodExit).toBe(0);
+    await setupFakeClipboard(dir, clipOutputFile);
 ...
   test.skipIf(isWindows)(".copy pipes string value without quotes", async () => {
     using dir = tempDir("repl-copy-test2", {});
 
     const clipOutputFile = `${dir}/clip-output.txt`;
-    const fakeClipboard = `${dir}/${fakeClipboardName}`;
-    await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
-    const { exitCode: chmodExit } = Bun.spawnSync({
-      cmd: ["chmod", "+x", fakeClipboard],
-    });
-    expect(chmodExit).toBe(0);
+    await setupFakeClipboard(dir, clipOutputFile);

Also applies to: 76-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/27671.test.ts` around lines 45 - 53, The test
duplicates fake clipboard setup (creating clipOutputFile, writing the shell
script to fakeClipboard, and chmod) in two places; extract that logic into a
single helper (e.g., createFakeClipboard or setupFakeClipboard) and replace both
blocks with calls to it. The helper should accept dir and fakeClipboardName (and
optionally clipOutputFile path) and perform the Bun.write and Bun.spawnSync
chmod steps, returning any paths or results needed by tests so existing
references to clipOutputFile, fakeClipboard, and fakeClipboardName work
unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 8-9: Tests use the host PATH and only mock one clipboard binary
(fakeClipboardName), making them flaky; update the test setup (in this file's
test cases that call .copy) to set a controlled PATH that points to a temp
directory with mocked executables for all candidate clipboard tools (e.g.,
pbcopy, xclip, wl-copy) and ensure each candidate binary is present (even if a
no-op) so probe order cannot hit host tools; reference the
fakeClipboardName/isMacOS logic and the .copy test cases to locate where to
override process.env.PATH and install the mocked binaries, then restore PATH
after the test.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 62ae8ab and 55e9e47.

📒 Files selected for processing (1)
  • test/regression/issue/27671.test.ts
Comment on lines +8 to +9
const fakeClipboardName = isMacOS ? "pbcopy" : "xclip";

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Harden external-tool tests against host PATH leakage.

Line 59 and Line 80 keep the full host PATH, while only one Linux tool is mocked. That makes these tests environment-dependent if another supported clipboard binary is preferred in probe order. Prefer isolating PATH and mocking all candidate tools used by .copy.

Suggested hardening
-const fakeClipboardName = isMacOS ? "pbcopy" : "xclip";
+const fakeClipboardNames = isMacOS ? ["pbcopy"] : ["xclip", "xsel", "wl-copy"];

 async function setupFakeClipboard(dir: string): Promise<string> {
   const clipOutputFile = `${dir}/clip-output.txt`;
-  const fakeClipboard = `${dir}/${fakeClipboardName}`;
-  await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
-  const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
-  expect(exitCode).toBe(0);
+  for (const name of fakeClipboardNames) {
+    const fakeClipboard = `${dir}/${name}`;
+    await Bun.write(fakeClipboard, `#!/bin/sh\n/bin/cat > "${clipOutputFile}"\n`);
+    const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
+    expect(exitCode).toBe(0);
+  }
   return clipOutputFile;
 }
 ...
-        PATH: `${dir}:${process.env.PATH}`,
+        PATH: `${dir}:${path.dirname(bunExe())}`,
 ...
-        PATH: `${dir}:${process.env.PATH}`,
+        PATH: `${dir}:${path.dirname(bunExe())}`,

Also applies to: 43-50, 57-61, 78-81

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/27671.test.ts` around lines 8 - 9, Tests use the host
PATH and only mock one clipboard binary (fakeClipboardName), making them flaky;
update the test setup (in this file's test cases that call .copy) to set a
controlled PATH that points to a temp directory with mocked executables for all
candidate clipboard tools (e.g., pbcopy, xclip, wl-copy) and ensure each
candidate binary is present (even if a no-op) so probe order cannot hit host
tools; reference the fakeClipboardName/isMacOS logic and the .copy test cases to
locate where to override process.env.PATH and install the mocked binaries, then
restore PATH after the test.
Comment on lines +28 to +30
TERM: "dumb",
NO_COLOR: "1",
...env,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: The runRepl helper reads stdout/stderr sequentially after await proc.exited (lines 28-30) instead of using Promise.all as prescribed by test/CLAUDE.md, and wraps streams in new Response() instead of calling .text() directly. Additionally, line 99 uses inline require("path") instead of a module-scope import path from "node:path", violating test/CLAUDE.md guidelines on dynamic require.

Extended reasoning...

Sequential stdout/stderr reading

The runRepl helper at lines 28-30 uses this pattern:

const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();

The test/CLAUDE.md (lines 42-46) documents the correct pattern as:

const [stdout, stderr, exitCode] = await Promise.all([
  proc.stdout.text(),
  proc.stderr.text(),
  proc.exited,
]);

The sequential pattern has a theoretical risk: if a process writes enough data to stdout/stderr to fill the OS pipe buffer (typically 64KB on Linux), the process blocks on the write syscall and never exits. Since the test awaits proc.exited first, it would deadlock. In practice, the REPL output for .copy 42 followed by .exit is only ~200 bytes, so this will never trigger. Additionally, the code wraps stdout in new Response(proc.stdout).text() rather than using the idiomatic Bun API proc.stdout.text() directly.

Inline require instead of module-scope import

Line 99 uses require("path").dirname(bunExe()) inline. The test/CLAUDE.md (lines 218-220) explicitly states: "Only use dynamic import or require when the test is specifically testing something related to dynamic import or require. Otherwise, always use module-scope import statements." This test is not testing require behavior — it is simply computing a directory path.

Step-by-step proof

  1. Looking at line 99: PATH: \${dir}:${require("path").dirname(bunExe())}`` — this is a plain utility call to get the directory of the bun executable, not a test of dynamic require semantics.
  2. Looking at lines 28-30: await proc.exited completes first, then new Response(proc.stdout).text() is called. If the REPL ever produced >64KB output before these reads started, the process would block writing to the pipe, never exit, and the await proc.exited would hang forever.
  3. The fix for the sequential issue is to use Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) which reads streams concurrently with waiting for exit.
  4. The fix for the require issue is to add import path from "node:path"; at the top of the file and replace require("path").dirname(bunExe()) with path.dirname(bunExe()).

Impact

Both issues are purely convention violations in test code with no practical impact on correctness. The sequential read pattern could theoretically cause test flakiness if REPL output grew significantly, but that scenario is extremely unlikely for these specific test cases.

Comment on lines +38 to +50
return { stdout, stderr, exitCode };
}

// Create a fake clipboard tool that writes stdin to a capture file.
// Returns the path to the capture file.
async function setupFakeClipboard(dir: string): Promise<string> {
const clipOutputFile = `${dir}/clip-output.txt`;
const fakeClipboard = `${dir}/${fakeClipboardName}`;
await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
expect(exitCode).toBe(0);
return clipOutputFile;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The first two tests create a fake xclip and prepend it to PATH, but copyToClipboardExternal uses comptime platform checks — on macOS builds only pbcopy is tried, never xclip. So on macOS the fake xclip is never invoked, clipOutputFile is never written, and Bun.file(clipOutputFile).text() will throw. These tests should also skip on macOS (e.g. test.skipIf(!isLinux)) or create a fake pbcopy on macOS instead.

Extended reasoning...

What the bug is

The first two tests in 27671.test.ts (lines 38-62 and 64-86) create a fake xclip shell script that writes stdin to a file, then verify clipboard content by reading that file. However, the tests only skip on Windows (test.skipIf(isWindows)) and will fail on macOS.

Why it fails on macOS

The Zig function copyToClipboardExternal in src/repl.zig uses comptime platform checks to decide which clipboard tools to try:

const commands: []const ClipboardCmd = if (Environment.isMac)
    &.{ .{ .argv = &.{"pbcopy"} } }
else if (Environment.isLinux)
    &.{ ... xclip, xsel, wl-copy ... }
else
    return false;

Because these are comptime checks, on a macOS-compiled binary the code literally only contains pbcopy — the xclip branch is compiled out entirely.

Step-by-step proof on macOS

  1. Test creates ${dir}/xclip script that writes stdin to clipOutputFile
  2. Test sets PATH=${dir}:${process.env.PATH} so fake xclip is first in PATH
  3. REPL runs .copy 42, which calls copyToClipboardExternal
  4. On macOS, only pbcopy is in the compiled command list — xclip is never tried
  5. pbcopy is found on the real system PATH and copies 42 to the actual system clipboard
  6. The fake xclip script is never invoked, so clipOutputFile is never created
  7. await Bun.file(clipOutputFile).text() throws because the file does not exist
  8. Test fails

Impact

This is a test-only issue (nit severity) but will cause CI failures on any macOS runner. It does not affect production code.

How to fix

The simplest fix is to restrict these two tests to Linux only, since the fake xclip approach only works there. The harness already exports isLinux:

import { bunEnv, bunExe, isWindows, isLinux } from "harness";
test.skipIf(!isLinux)(".copy pipes text to external clipboard tool", ...);
test.skipIf(!isLinux)(".copy pipes string value without quotes", ...);

Alternatively, the tests could detect the platform and create a fake pbcopy on macOS instead of (or in addition to) xclip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
test/regression/issue/27671.test.ts (1)

7-9: ⚠️ Potential issue | 🟠 Major

External-tool tests are still non-hermetic due to host PATH inheritance.

These tests can still pick host clipboard binaries if probe order changes or if the mocked tool path is bypassed, making outcomes environment-dependent. Mock all candidate clipboard tools and isolate PATH to the temp dir.

Suggested hardening diff
-const fakeClipboardName = isMacOS ? "pbcopy" : "xclip";
+const fakeClipboardNames = isMacOS ? ["pbcopy"] : ["xclip", "xsel", "wl-copy"];

 async function setupFakeClipboard(dir: string): Promise<string> {
   const clipOutputFile = `${dir}/clip-output.txt`;
-  const fakeClipboard = `${dir}/${fakeClipboardName}`;
-  await Bun.write(fakeClipboard, `#!/bin/sh\ncat > "${clipOutputFile}"\n`);
-  const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
-  expect(exitCode).toBe(0);
+  for (const tool of fakeClipboardNames) {
+    const fakeClipboard = `${dir}/${tool}`;
+    await Bun.write(fakeClipboard, `#!/bin/sh\n/bin/cat > "${clipOutputFile}"\n`);
+    const { exitCode } = Bun.spawnSync({ cmd: ["chmod", "+x", fakeClipboard] });
+    expect(exitCode).toBe(0);
+  }
   return clipOutputFile;
 }
...
-        PATH: `${dir}:${process.env.PATH}`,
+        PATH: `${dir}`,
...
-        PATH: `${dir}:${process.env.PATH}`,
+        PATH: `${dir}`,

Also applies to: 43-50, 57-61, 79-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/27671.test.ts` around lines 7 - 9, The tests still
inherit the host PATH and only mock a single clipboard binary via
fakeClipboardName, making them non-hermetic; update the test setup (where
fakeClipboardName and isMacOS are used) to create a temporary bin directory,
create stub executables for every candidate clipboard tool (e.g., "pbcopy",
"pbpaste", "xclip", "xsel", etc.), prepend that temp bin to PATH (or set PATH to
only that dir) for the test process, and ensure cleanup after tests; use the
same helper setup for the other occurrences noted (around the blocks referenced
at 43-50, 57-61, 79-82) so all clipboard probes are mocked and the tests are
isolated from host binaries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/regression/issue/27671.test.ts`:
- Around line 7-9: The tests still inherit the host PATH and only mock a single
clipboard binary via fakeClipboardName, making them non-hermetic; update the
test setup (where fakeClipboardName and isMacOS are used) to create a temporary
bin directory, create stub executables for every candidate clipboard tool (e.g.,
"pbcopy", "pbpaste", "xclip", "xsel", etc.), prepend that temp bin to PATH (or
set PATH to only that dir) for the test process, and ensure cleanup after tests;
use the same helper setup for the other occurrences noted (around the blocks
referenced at 43-50, 57-61, 79-82) so all clipboard probes are mocked and the
tests are isolated from host binaries.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 55e9e47 and 283427b.

📒 Files selected for processing (1)
  • test/regression/issue/27671.test.ts
Comment on lines +1507 to +1515
return false;

for (commands) |cmd| {
var child = std.process.Child.init(cmd.argv, bun.default_allocator);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Ignore;
child.stderr_behavior = .Ignore;

child.spawn() catch continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Potential deadlock in copyToClipboardExternal: if stdin.writeAll(text) fails with a non-EPIPE error while the child process is still alive, the error path (line 1519) calls child.wait() without first closing stdin. Since clipboard tools read until EOF, the child blocks forever and wait() hangs. Add stdin.close(); child.stdin = null; before the child.wait() call in the catch block.

Extended reasoning...

What the bug is

In copyToClipboardExternal (lines 1517-1524), when stdin.writeAll(text) succeeds, stdin is properly closed before calling child.wait(). However, the error path at lines 1518-1520 skips straight to child.wait() without closing stdin first:

stdin.writeAll(text) catch {
    _ = child.wait() catch {};
    continue;
};
stdin.close();
child.stdin = null;

How the deadlock occurs

Clipboard tools like xclip, pbcopy, xsel, and wl-copy all read from stdin until they receive EOF (i.e., the write end of the pipe is closed). If writeAll fails for a reason other than EPIPE (e.g., EIO, or a partial write failure on large data), the child process may still be alive waiting to read more input. The parent then calls child.wait(), which internally calls waitpid() -- but the child never exits because it never sees EOF on its stdin. This creates a classic deadlock: the parent waits for the child, and the child waits for EOF from the parent.

I verified this by examining the Zig standard library: std.process.Child.wait() calls waitpid() first, and only calls cleanupStreams() (which would close stdin) after waitpid returns. So stdin is definitively not closed before the wait.

Step-by-step proof

  1. child.spawn() succeeds -- clipboard tool (e.g., xclip) starts and begins reading from its stdin pipe
  2. stdin.writeAll(text) fails with an error other than EPIPE (e.g., EIO on a degraded system, or partial failure on very large clipboard content)
  3. The child process is still alive, blocked on read(0, ...) waiting for more data or EOF
  4. Error path executes: _ = child.wait() catch {};
  5. child.wait() calls waitpid() which blocks because the child hasn't exited
  6. The child never exits because stdin is still open (no EOF signal)
  7. Deadlock: parent blocked on waitpid, child blocked on read

Practical impact

The most common pipe write failure is EPIPE (broken pipe), which means the child has already exited -- in that case wait() returns immediately and there is no deadlock. Other failure modes like EIO are rare for local pipes. So while this is a real code defect, the practical likelihood of triggering it is very low. The clipboard content is typically small (well under the 64KB pipe buffer), further reducing the chance of partial write failures.

How to fix

Add stdin.close() and child.stdin = null before the child.wait() call in the error path:

stdin.writeAll(text) catch {
    stdin.close();
    child.stdin = null;
    _ = child.wait() catch {};
    continue;
};

This ensures the child receives EOF and can exit cleanly, allowing wait() to return.

Comment on lines +99 to +103
const { stdout, exitCode } = await runRepl([".copy 42", ".exit"], {
env: {
// Only bun's directory and empty dir - no clipboard tools available
PATH: `${dir}:${path.dirname(bunExe())}`,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The OSC 52 fallback test (line 102) sets PATH to ${dir}:${path.dirname(bunExe())}, but if bun is installed in a system directory like /usr/bin, that directory likely contains xclip/xsel, so the external clipboard tool succeeds instead of falling back to OSC 52, and the expect(stdout).toContain("\x1b]52;c;") assertion on line 107 fails. Fix by symlinking bun into the temp dir so PATH never includes a system bin directory.

Extended reasoning...

What the bug is

The third test (".copy falls back to OSC 52 when no external tools available") constructs a PATH intended to contain no clipboard tools. It does this by combining the temp dir with path.dirname(bunExe()). The assumption (stated in the comment on line 101) is that bun's directory contains only bun and no clipboard tools.

The specific code path that triggers failure

On line 102: PATH: \${dir}:${path.dirname(bunExe())}``

If bun is installed via a system package manager (e.g., apt install bun or brew install bun), bunExe() returns something like /usr/bin/bun or /usr/local/bin/bun. Then path.dirname(bunExe()) returns /usr/bin or /usr/local/bin. These directories commonly contain xclip, xsel, or pbcopy — the exact tools that copyToClipboardExternal probes for.

Step-by-step proof

  1. Assume bun is at /usr/bin/bun (installed via system package manager)
  2. path.dirname(bunExe()) returns /usr/bin
  3. PATH is set to ${dir}:/usr/bin
  4. The REPL runs .copy 42, which calls copyToClipboardExternal
  5. On Linux, copyToClipboardExternal tries xclip, xsel, wl-copy in order
  6. /usr/bin/xclip exists on many Linux systems — it spawns successfully and exits 0
  7. copyToClipboardExternal returns true, so the OSC 52 fallback is never reached
  8. expect(stdout).toContain("\x1b]52;c;") on line 107 fails because the OSC 52 sequence was never emitted

Why existing code doesn't prevent it

The test comment on line 101 says "Only bun's directory and empty dir - no clipboard tools available" but this assumption is only valid when bun lives in a dedicated build directory (like ./build/bun or ~/.bun/bin/bun), not when it shares a directory with system utilities.

Impact

This is a test-only fragility issue. It won't affect production code. In oven-sh/bun CI, the bun binary typically lives in a dedicated build output directory, so the test passes there. However, any developer running tests with a system-installed bun (e.g., from a package manager) would see this test unexpectedly fail.

How to fix

Symlink bun into the temp directory so PATH never includes a system directory:

const binDir = `${dir}/bin`;
fs.mkdirSync(binDir);
fs.symlinkSync(bunExe(), `${binDir}/bun`);
// ...
PATH: `${dir}:${binDir}`,

This ensures the PATH contains only the empty temp dir and a directory with just the bun symlink — no clipboard tools can leak in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1 participant