Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 38 additions & 28 deletions scripts/orderfile/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,36 @@ interface Workload {
env?: Record<string, string>;
}

export interface RunOptions {
env?: Record<string, string | undefined> | undefined;
cwd?: string | undefined;
input?: string | undefined;
timeout?: number | undefined;
/** How the command is named in errors. Defaults to the executable. */
label?: string | undefined;
}

/**
* Runs a command to completion, throwing if it could not be spawned. Exported so
* a test can drive it under node: bun's spawnSync delivers `input` whatever stdin
* is, so the wiring below only ever breaks on CI, which builds under node.
*/
export function runCommand(cmd: string[], options: RunOptions = {}) {
const r = spawnSync(cmd[0]!, cmd.slice(1), {
env: { ...process.env, ...options.env },
cwd: options.cwd,
input: options.input,
timeout: options.timeout,
// Only a pipe carries `input`: node drops it when stdin is "ignore", and
// then an interactive workload reads nothing and waits forever for a line.
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
maxBuffer: 1 << 29, // nm prints ~10 MB of symbols
});
// A timeout arrives here too: spawnSync reports it as an ETIMEDOUT error.
if (r.error) throw new Error(`${options.label ?? cmd[0]}: ${r.error.message}`);
return r;
}

export interface GenerateOptions {
/** Build directory holding the unstripped binary. */
buildDir: string;
Expand Down Expand Up @@ -110,38 +140,15 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou
throw new Error(`${bunProfile} not found — build it first (bun run build:release)`);
}

interface RunOptions {
env?: Record<string, string | undefined> | undefined;
cwd?: string | undefined;
input?: string | undefined;
timeout?: number | undefined;
/** How the command is named in errors. Defaults to the executable. */
label?: string | undefined;
}

function run(cmd: string[], options: RunOptions = {}) {
const r = spawnSync(cmd[0]!, cmd.slice(1), {
env: { ...process.env, ...options.env },
cwd: options.cwd,
input: options.input,
timeout: options.timeout,
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1 << 29, // nm prints ~10 MB of symbols
});
// A timeout arrives here too: spawnSync reports it as an ETIMEDOUT error.
if (r.error) throw new Error(`${options.label ?? cmd[0]}: ${r.error.message}`);
return r;
}

const scratch = mkdtempSync(join(tmpdir(), "bun-orderfile-"));
try {
// ── Build the tracer and the pty runner ───────────────────────────────────
const tracer = join(scratch, "pagetrace.so");
const ptyrun = join(scratch, "ptyrun");
const cc = process.env.CC || "cc";
const build = run([cc, "-O2", "-shared", "-fPIC", "-o", tracer, join(here, "pagetrace.c"), "-ldl"]);
const build = runCommand([cc, "-O2", "-shared", "-fPIC", "-o", tracer, join(here, "pagetrace.c"), "-ldl"]);
if (build.status !== 0) throw new Error(`failed to build the tracer with ${cc}\n${build.stderr}`);
const pty = run([cc, "-O2", "-o", ptyrun, join(here, "ptyrun.c"), "-lutil"]);
const pty = runCommand([cc, "-O2", "-o", ptyrun, join(here, "ptyrun.c"), "-lutil"]);
if (pty.status !== 0) throw new Error(`failed to build the pty runner with ${cc}\n${pty.stderr}`);

// ── Representative workloads ──────────────────────────────────────────────
Expand Down Expand Up @@ -179,7 +186,10 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou
`{ "name": "orderfile-dep", "version": "1.0.0", "main": "index.js" }\n`,
);
writeFileSync(join(dependency, "index.js"), `module.exports = 1;\n`);
const pack = run([bunProfile, "pm", "pack", "--filename", "dep.tgz"], { cwd: dependency, label: "bun pm pack" });
const pack = runCommand([bunProfile, "pm", "pack", "--filename", "dep.tgz"], {
cwd: dependency,
label: "bun pm pack",
});
if (pack.status !== 0) throw new Error(`could not pack the install fixture\n${pack.stderr}`);
writeFileSync(
join(app, "package.json"),
Expand Down Expand Up @@ -211,7 +221,7 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou
// The tracer loads into the traced process and nowhere else. On a terminal
// ptyrun is the parent, so it is the one that hands the preload down.
const preload = workload.tty ? { PTYRUN_PRELOAD: tracer } : { LD_PRELOAD: tracer };
const r = run(workload.tty ? [ptyrun, bunProfile, ...workload.args] : [bunProfile, ...workload.args], {
const r = runCommand(workload.tty ? [ptyrun, bunProfile, ...workload.args] : [bunProfile, ...workload.args], {
env: {
...preload,
BUN_PAGETRACE_BIN: bunProfile,
Expand Down Expand Up @@ -242,7 +252,7 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou

// ── Symbol table ──────────────────────────────────────────────────────────
const nm = process.env.NM || (existsSync("/usr/bin/llvm-nm") ? "llvm-nm" : "nm");
const symbols = run([nm, "--defined-only", "-S", "--numeric-sort", bunProfile]);
const symbols = runCommand([nm, "--defined-only", "-S", "--numeric-sort", bunProfile]);
if (symbols.status !== 0) throw new Error(`${nm} failed on ${bunProfile}\n${symbols.stderr}`);

// `t`/`T` only: ordering `.rodata.*` separates constants from the mergeable
Expand Down
45 changes: 44 additions & 1 deletion test/js/bun/perf/linker-order.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { bunEnv, bunExe, nodeExe, tempDir } from "harness";
import { join } from "node:path";
import {
mustGenerateOrderFile,
Expand Down Expand Up @@ -154,6 +154,49 @@ describe("order file generator", () => {
});
});

/**
* CI builds with `node --experimental-strip-types scripts/build.ts`, so the
* workloads are spawned by node's spawnSync, not bun's. Node only delivers
* `input` when stdin is a pipe, and silently drops it when stdin is "ignore";
* bun delivers it either way, so nothing a developer runs locally notices. The
* interactive workloads are the only ones typed anything, and the ~2k tty and
* readline functions they exist to trace are unreachable without it.
*/
describe.skipIf(process.platform !== "linux" || !nodeExe())("interactive workload stdin", () => {
it("reaches the workload when the generator runs under node, as CI does", async () => {
await using proc = Bun.spawn({
cmd: [
nodeExe()!,
"--experimental-strip-types",
join(import.meta.dir, "orderfile-workload-fixture.ts"),
bunExe(),
join(import.meta.dir, "../../../../scripts/orderfile/cli-fixture.js"),
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// node warns about the fixture's module type on every run, so stderr is never
// empty; an uncaught error is the part worth reading. It is also how this
// notices generate.ts growing TypeScript that node cannot strip, which would
// break the real build the same way.
const crash = /^\w*Error\b.*/m.exec(stderr)?.[0] ?? null;

// cli-fixture.js answers `name?` with the first line it is typed and counts
// the rest, so "read 0 lines" is what an empty stdin looks like. On a
// terminal it is worse: readline waits for a line that never arrives, and
// the workload times out instead of returning at all.
expect({
greeted: stdout.includes("hi world"),
read: /read (\d+) lines/.exec(stdout)?.[1],
crash,
exitCode,
}).toEqual({ greeted: true, read: "3", crash: null, exitCode: 0 });
});
});

const compiler = process.env.CC || Bun.which("cc") || Bun.which("clang") || Bun.which("gcc");

async function compile(args: string[]) {
Expand Down
18 changes: 18 additions & 0 deletions test/js/bun/perf/orderfile-workload-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading