diff --git a/test/js/bun/shell/shell-blocking-pipe.test.ts b/test/js/bun/shell/shell-blocking-pipe.test.ts index 3249b2caa3a6..be2b071a87d0 100644 --- a/test/js/bun/shell/shell-blocking-pipe.test.ts +++ b/test/js/bun/shell/shell-blocking-pipe.test.ts @@ -1,23 +1,19 @@ -import { $, generateHeapSnapshot } from "bun"; +import { $ } from "bun"; -import { test } from "bun:test"; -import { isWindows } from "harness"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows } from "harness"; +import { join } from "node:path"; -// We skip this test on Windows becasue: +// We skip these tests on Windows because: // 1. Windows didn't have this problem to begin with // 2. We need system cat. test.skipIf(isWindows)("writing > send buffer size doesn't block the main thread", async () => { const expected = Buffer.alloc(1024 * 1024, "bun!").toString(); const massiveComamnd = "echo " + expected + " | " + Bun.which("cat"); - const pendingResult = $`${{ + const result = await $`${{ raw: massiveComamnd, }}`.text(); - // Ensure that heap snapshot works, to excercise the memoryCost & estimated fields. - generateHeapSnapshot("v8"); - - const result = await pendingResult; - if (result !== expected + "\n") { throw new Error("Expected " + expected + "\n but got " + result); } @@ -31,3 +27,25 @@ test.skipIf(isWindows)("writing > send buffer size (with a variable) doesn't blo throw new Error("Expected " + expected + "\n but got " + result); } }); + +// The snapshots are taken in a separate process; the fixture explains why. +test.skipIf(isWindows)("heap snapshots report the script held by a pending shell command", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "shell-heap-snapshot-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const { scriptLength, parsedScriptBeforeRun, interpreterWhileRunning, interpreterAfterExit, stdoutMatches } = + JSON.parse(stdout); + expect(parsedScriptBeforeRun).toBeGreaterThanOrEqual(scriptLength); + expect(interpreterWhileRunning).toBeGreaterThanOrEqual(scriptLength); + expect(interpreterAfterExit).toBeLessThan(scriptLength); + // The second snapshot ran a full GC underneath the running command; its + // output must still come through intact. + expect(stdoutMatches).toBe(true); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/shell/shell-heap-snapshot-fixture.ts b/test/js/bun/shell/shell-heap-snapshot-fixture.ts new file mode 100644 index 000000000000..c7468ccdeeeb --- /dev/null +++ b/test/js/bun/shell/shell-heap-snapshot-fixture.ts @@ -0,0 +1,56 @@ +// Spawned by shell-blocking-pipe.test.ts. +// +// A heap snapshot asks every ParsedShellScript and ShellInterpreter in the heap +// for its memoryCost(). Neither object is reachable from JS, so a snapshot is +// the only way a test gets those hooks called. A snapshot walks the whole heap, +// and the heap of a `bun test` process is several times the size of this one +// (under a debug + ASAN build, one snapshot of it outlasts the default test +// timeout), so the snapshots are taken here, in a process of their own. +import { $, generateHeapSnapshot } from "bun"; + +// Large enough that a memoryCost() which counts the script is clearly +// distinguishable from one which does not. +const script = Buffer.alloc(1024 * 1024, "bun!").toString(); + +// Bytes reported by every cell named `className`: its instances plus its +// prototype, which reports a few bytes of its own. Each cell's entry in +// `nodes` starts with id, size, index into nodeClassNames. +function reportedSize(className: string): number { + const { nodes, nodeClassNames, type } = generateHeapSnapshot(); + const nodeStride = type === "GCDebugging" ? 7 : 4; + const classNameIndex = nodeClassNames.indexOf(className); + let size = 0; + for (let i = 0; i < nodes.length; i += nodeStride) { + if (nodes[i + 2] === classNameIndex) size += nodes[i + 1]; + } + return size; +} + +// Bun.which("cat") is the system cat, not the shell's builtin: with a child +// process in the pipeline the command cannot finish until the event loop runs. +const promise = $`${{ raw: "echo " + script + " | " + Bun.which("cat") }}`.quiet(); + +// Nothing has run yet: the ParsedShellScript owns the parsed script. +const parsedScriptBeforeRun = reportedSize("ParsedShellScript"); + +// then() is what starts the command. The parsed script now belongs to the +// interpreter, which is still running when the snapshot is taken because we +// have not yielded to the event loop yet. +const running = promise.then(output => output.text()); +const interpreterWhileRunning = reportedSize("ShellInterpreter"); + +const stdout = await running; + +// Finishing releases the parsed script, whether or not the interpreter +// itself has been collected yet. +const interpreterAfterExit = reportedSize("ShellInterpreter"); + +console.log( + JSON.stringify({ + scriptLength: script.length, + parsedScriptBeforeRun, + interpreterWhileRunning, + interpreterAfterExit, + stdoutMatches: stdout === script + "\n", + }), +);