Test and debug a compiled program

Run your program and its tests under the compiler's rules, find the guards that fail, and read an executable's stack traces, crash reports and profiles

Ahead-of-time compilation is experimental.

An executable built with bun compile runs machine code where bun run has bytecode and a JIT. Your tests, stack traces, crash reports and profilers work with it. Breakpoints don't.

Before you ship:

  1. Get bun check --aot and bun test --aot to pass.
  2. Ship.
  3. If it's slower than you expected, read bun compile --explain, then --explain=guards, then run with --aot-log-type-mismatches under real traffic.

The examples use this program.

app.ts
function double(n: number): number {
  return n * 2;
}

const body: any = JSON.parse('{"visits":"3"}');
console.log(double(body.visits));
console.log((body.visits as number) + 1);
terminal
bun app.ts
6
31

Run under the compiler's rules#

bun run --aot and bun test --aot are bun run and bun test with two things added. Your program still runs from its source files, with the JIT. No machine code is generated ahead of time.

  1. Before anything runs, Bun checks the program as bun compile does. A type error, or anything else that stops the build, is printed as the build prints it, and the process exits with code 1.
  2. Built-in objects are read-only, as in an executable. Array.prototype.push = mine throws the same TypeError with the same message.
terminal
bun run --aot app.ts
6
31

The string "3" doesn't match n: number. Nothing throws because of a type annotation, here or in an executable, so the output is the same.

  • The check looks at the whole program at every start. Nothing is cached. It ends where the compiler would start to generate code, so it takes a fraction of the time of a build. What only shows later isn't reported: a function whose stack frame is too large, a program with too many functions, a regular expression or a .wasm module that falls back to the interpreter.
  • In bun test --aot, a test file that wouldn't build fails, and the other files run. A test file that imports a file with an error fails too. An error in a --preload file stops the run, and so does any error under --parallel.
  • Mocks work. mock.module() and spyOn() on the exports of a module do what they do in bun test. An executable has no support for either. There they throw TypeError: mock(module, fn) cannot replace a module that was compiled ahead of time and TypeError: Cannot spy on 'price' because module exports can't be replaced in a compiled executable.
  • spyOn() works on built-in objects too, as in spyOn(String.prototype, "normalize"), and mockRestore() puts the original back. An assignment to the same member still throws.
  • bun test --aot ends with a list of the writes to read-only built-ins that failed, whether or not something caught the TypeError: what was written, where, and which test file was running. Code that swallows such an error may have left its work half done. Under --parallel there is no list.
  • --rerun-each, --parallel, --isolate and --coverage work as in bun test.
  • --inspect, --inspect-wait, --inspect-brk and BUN_INSPECT work. The debugger starts after the check, in the program that runs, so breakpoints bind as in bun run.
  • --watch works. When a file changes, Bun checks the program again and then restarts it. It watches every file the check reads outside of node_modules, so a file that has only types and your tsconfig.json count too. If the check fails, Bun prints the errors and waits for the next change.
  • --hot restarts the process like --watch, so that Bun checks the code again before it runs. --hot --check does the same.
  • -e and -p can't be combined with --aot. Bun prints an error and exits.
  • --aot takes a file, not a package.json script. For a script, put the flag in its command: "dev": "bun --aot server.ts".

The rest of what differs in an executable doesn't show under these two commands, because the code that runs isn't compiled. To see it, build the executable and run that.

Find the guards that fail#

A guard that fails doesn't change what your program does. It costs speed, because the function falls back to its slow path. If a guard fails on every call of a hot function, that function never runs its fast version.

CommandFindsYour program
bun check --aot --explain=guardsEvery guard that can fail, from your source codeDoesn't run
--aot-log-type-mismatches on bun run, bun test, bun compileThe guards that do fail, with your dataIs compiled, runs, and logs

Neither needs a change to your source code.

List them from your source#

--explain=guards lists every type guard that the compiler couldn't remove.

terminal
bun check --aot --explain=guards app.ts
note: 2 type guards can fail at run time. When one fails, the function falls back to its slow path.

note: By cause: 2 on a value from untyped code.

1 | function double(n: number): number {
                    ^
note: 1. the value may come from untyped code
   at app.ts:1:17

note: in function 'double'
note: fix: narrow it first: typeof for a primitive. For an object, check its fields and build a new object of the type from them
note: id 772073ca2bba (argument)

7 | console.log((body.visits as number) + 1);
                 ^
note: 2. the value may come from untyped code
   at app.ts:7:14

7 | console.log((body.visits as number) + 1);
                                ^
note: The type is declared here.
   at app.ts:7:29
note: fix: narrow it first: typeof for a primitive. For an object, check its fields and build a new object of the type from them
note: id 5a3b0741d252 (cast)

Each entry is one guard: its location, why the compiler couldn't remove it, where the type is declared, the function around it, and the fix.

  • Code at the top level of a module, as in the second entry, has no in function line.
  • A parameter's guard is listed at the parameter, however many calls can fail it. So the first entry points to n, not to the call in line 6.
  • The last line has the guard's id and its kind. The log uses both.

--explain=guards-json prints the same list to stdout, one JSON object per line. bun compile takes both flags too. On a large program, --explain=guards prints at most 200 guards per cause and counts the rest.

Log them at run time#

--explain=guards only reads your source code, so it can't know what's in your data. With --aot-log-type-mismatches, the compiled program writes a line to stderr when a value doesn't match its declared type, and continues.

terminal
bun --aot-log-type-mismatches app.ts
[aot] type-mismatch 772073ca2bba argument app.ts:1:17 (double): expected number, got string; continued; count 1; from app.ts:6
6
[aot] type-mismatch 5a3b0741d252 cast app.ts:7:14 (module): expected number, got string; continued; count 1
31
CommandUse it for
bun run --aot-log-type-mismatchesOne run on your machine. Bun compiles the program, runs the machine code and writes no executable
bun test --aot-log-type-mismatchesThe data in your tests
bun compile --aot-log-type-mismatchesReal traffic. The executable logs wherever you deploy it. In Bun.build, compile: { aotLogTypeMismatches: true }
  • The program behaves the same. It prints what it prints under bun run, and exits with the same code.
  • It's slower. A build that logs doesn't rely on your types. Use it to find mismatches, not to measure speed.
  • It has no guards to list. --explain=guards says so in such a build. Run the report without the flag.
  • A line never contains a value from your program, only its kind, such as string or untyped object.
  • Each site logs its 1st, 10th, 100th mismatch and so on, and a total when the process exits. The log has room for 200 sites. After that it says once that it's full. Each Worker counts for itself.
  • There's no way to silence a line. If the value is right and the type isn't, change the type.

Set BUN_AOT_TYPE_MISMATCH_LOG to a file to append the lines there, to stderr, or to off. BUN_AOT_TYPE_MISMATCH_LOG_FORMAT is text or json. The default is text for stderr and json for a file.

To fail a CI job, write the lines to a file and test that it's empty. Bun appends to the file, so remove it first.

terminal
rm -f mismatches.jsonl
BUN_AOT_TYPE_MISMATCH_LOG=mismatches.jsonl bun test --aot-log-type-mismatches && test ! -s mismatches.jsonl

WebAssembly that falls back to the interpreter#

The log has one more kind of line. A .wasm file that your program imports is compiled with it. Where the executable can't use that code, Bun interprets the module, which is much slower, and writes a line.

[aot] interpreter-fallback no-guard-pages: a compiled WebAssembly module is running in the interpreter because its memory has no guard pages
The line saysCause
no-guard-pagesThe instance got a memory without guard pages, such as one with maximum: 0. Other instances of the module still run compiled code
no-simdThe module uses SIMD instructions, and they aren't available on this CPU

Each reason is logged once per process. An executable built without --aot-log-type-mismatches only writes the line if BUN_AOT_TYPE_MISMATCH_LOG is a file or stderr.

Read a line#

[aot] type-mismatch 772073ca2bba argument app.ts:1:17 (double): expected number, got string; continued; count 1; from app.ts:6
PartMeaning
772073ca2bbaThe guard's id. It's the same in --explain=guards and in the log, from run to run, until the line moves
argumentThe kind of guard, from the table below
app.ts:1:17 (double)File, line, column and function. module is code outside of any function
expected ..., got ...The declared type, and the kind of value that arrived
continuedThe program continued with the value, as it does under bun run
count 1How often this guard has failed with this kind of value
from a.ts:41 < b.ts:12The callers, nearest first, at most three
totalThe process is exiting, and this is the final count

In JSON, each line is an object with "aot": "type-mismatch" and the members id, kind, file, line, col, fn, expected, got, action, count, from and total.

KindThe guard is on
argumentA parameter
thisthis in a method
captureA variable that a closure captures
returnA function's return value
fieldA field of a class or an object type, static fields included
variableA variable
elementAn element of an array or a Set, a key or a value of a Map, the value of a promise
call-resultThe result of a call
propertyA property read
castAn as cast
unknownNothing the compiler recorded

Stack traces#

Stack traces have the file, line and column of your source code. You don't need a source map. To keep names and paths out of the executable, move them into an external source map.

repo.ts
class Repo {
  find(id: number): string {
    if (id < 0) throw new RangeError("bad id " + id);
    return "x";
  }
}
new Repo().find(-1);
terminal
bun compile ./repo.ts && ./repo
RangeError: bad id -1
      at find (/$bunfs/root/repo.ts:3:27)
      at /$bunfs/root/repo.ts:7:12
  • The path in a frame starts with /$bunfs/root/. What follows is the path of the source file, relative to the directory where you ran the build.
  • An executable prints no source excerpt. It doesn't hold the text of your files, and it never opens a file to show you one. bun run --aot runs your files where they are, so it prints the lines above the message, as bun run does.
  • Inlined functions keep their frames.
  • Some built-in functions have no frame. Compiled code reaches built-ins such as Array.prototype.push and eval without a call frame, so a trace can lack the at push (native) line that bun run prints.
  • A frame has the name its function had at build time. Redefining the name property of a function that's only called doesn't rename its frames.
  • A column can point at another part of the same expression. The line and column of a throw, of a call, of a property read and of a destructuring pattern are the same as under bun run. In a few expressions the two point at different words. Among them are the caller of a getter that an update through a computed key runs (o[key]++, o[key] += 1), and class A extends (function* () {}) {}.
  • A frame of one of Bun's own modules, such as node:events, has a line of that module's source file in Bun's repository. Under bun run it has a line of the module as it's built into Bun.
  • An executable can have a frame that bun run lacks. The transpiler of bun run turns return new Error("…") into a call in tail position, and a function leaves the stack before such a call. An executable keeps the new, so the function that made the error is the first frame.
  • Error.captureStackTrace, Error.prepareStackTrace, Error.stackTraceLimit, console.trace, async frames, uncaughtException and unhandledRejection work as usual.
  • Some common error messages are worded differently.

Minified executables and external source maps#

--minify shortens the names of functions, classes and variables in stack traces, in error messages and in name, and the executable no longer has the names you wrote. It's the same set of names that a bundle renames, but not the same letters. Methods and other properties keep their names, and so does everything that a direct eval can see. Paths, lines and columns are still those of your source files. With --keep-names, no name changes.

With --sourcemap=external, the names, paths, lines and columns move out of the executable and into a source map that Bun writes next to it. Every frame names the same file, compiled.js, and its line and column are a position in the map. --obfuscate is another name for the same option. The other values of --sourcemap have no effect on an executable that bun compile builds, and the build says so.

repo.ts
class Repo {
  find(id: number): string {
    if (id < 0) throw new RangeError("bad id " + id);
    return "x";
  }
}
new Repo().find(-1);
terminal
bun compile --minify --sourcemap=external ./repo.ts && ./repo

The numbers differ from one build to the next.

RangeError: bad id -1
      at <anonymous> (/$bunfs/root/compiled.js:4:2)
      at /$bunfs/root/compiled.js:1:4
  • There is no file compiled.js. The name is there so that tools that read stack traces find a path, a line and a column where they expect them. On Windows it's B:\~BUN\root\compiled.js.
  • The name of every function and class is "".
  • Error messages have a short name in place of the name of a variable, function or class, such as Cannot access 'a' before initialization. It's the short name that --minify gives it.
  • The map has the names as they are in your sources, with --minify too.
  • Property names, strings and the paths of your modules stay in the executable. The program needs them when it runs.

Bun writes two files next to the executable. The program reads neither, so don't ship them.

FileWhat it is
repo.mapThe source map. It leads from the line and column of a frame to the name, the file, the line and the column that a build without it prints
repo.map.jsTwo comments: the name of the map and its debug ID. Tools that upload source maps expect a JavaScript file next to each map
repo.map
{
  "version": 3,
  "file": "repo",
  "debugId": "f6a5af92-9a3e-3006-ba35-90ca7d50177d",
  "debug_id": "f6a5af92-9a3e-3006-ba35-90ca7d50177d",
  "sources": ["repo.ts"],
  "sourcesContent": ["class Repo {\n  find(id: number): string {\n …"],
  "names": ["Repo", "find"],
  "mappings": "AAAA,CAMI,CANJ,CAMW;AANLA,CAAAA;AACJC,CAAAA;AAAAA,CACwBA",
  "x_facebook_sources": [[{ "names": ["?", "Repo", "find"], "mappings": "AAA,MCA;ECC;IFK" }]]
}
  • debugId is the build ID of the executable, with the hyphens of a UUID. debug_id is the spelling that older tools read.
  • sourcesContent has the text of your files, so a tool can show the lines around an error.
  • The name in a mapping is the function that the position is in.
  • x_facebook_sources has the same names by position in the source file, in the format of Metro's function maps. Sentry takes the function of a frame from it. A function without a name is ?.
  • A built-in function such as Array.prototype.map has the source native, and a line that means nothing.

Any library that reads source maps can restore a trace. This script uses source-map-js.

restore.ts
import { SourceMapConsumer } from "source-map-js";

const map = new SourceMapConsumer(await Bun.file(process.argv[2]).json());
const restore = (text: string) =>
  text.replace(/at (?:<anonymous> \()?(?:\S*compiled\.js:)?(\d+):(\d+)\)?/g, (frame, line, column) => {
    const found = map.originalPositionFor({ line: Number(line), column: column - 1 });
    if (found.source === null) return frame;
    const place = `${found.source}:${found.line}:${found.column + 1}`;
    return found.name ? `at ${found.name} (${place})` : `at ${place}`;
  });
console.log(restore(await Bun.stdin.text()));
terminal
./repo 2>&1 | bun restore.ts repo.map

It prints what a build without the map prints, without the /$bunfs/root/ in front of each path.

RangeError: bad id -1
      at find (repo.ts:3:27)
      at repo.ts:7:12

Use with Sentry#

An executable with an external source map works with @sentry/bun and @sentry/node as they are. Sentry shows the files, lines, columns, function names and source lines of your code, and the executable has none of them.

Build

terminal
bun compile --minify --sourcemap=external ./src/index.ts --outfile dist/app

Upload the map

Pass the folder with app.map and app.map.js to sentry-cli. It skips the executable.

terminal
sentry-cli sourcemaps upload --org my-org --project my-project dist

Don't run sentry-cli sourcemaps inject. Both files have their debug ID already, and inject leaves them as they are.

Ship the executable alone

Deploy dist/app without the two files. Upload the map of every build that you deploy: each build has a debug ID of its own.

You don't set a release, a dist or a URL prefix, and you don't rewrite frames. Sentry finds the map of an event by its debug ID:

  • The SDK reads globalThis._debugIds or globalThis._sentryDebugIds, depending on its version. An executable with an external source map has both. Each has one entry, which pairs compiled.js with the debug ID. Bun creates them when something first reads them, and no other executable has them.
  • Each event has the pair in debug_meta.images.
  • It works for every error that has a stack: errors that you pass to Sentry.captureException, uncaught errors and unhandled rejections.

A crash of the process isn't a JavaScript error, so no SDK reports it. Its report has the same lines and columns.

Crashes#

When the process crashes, Bun prints its crash report to stderr. For a compiled executable, the report says which of your builds crashed, and it ends with the functions of your program that were on the stack.

============================================================
Bun v1.4.4 (24eaedc6) Linux x64
Build ID: 5f0c2a9e41d7b3860e9d4c17a2f3b658
Linux Kernel v6.1.170 | glibc v2.34
Args: "./app"
Features: jsc standalone_executable aot

panic(main thread): Segmentation fault at address 0x18
oh no: Bun has crashed. This indicates a bug in Bun, not your code.

 https://bun.report/1.4.4/l_24eaedc6E…CmBaot5f0c2a9e41d7b386mpuSCGaot6ovUCGaotit3U…

Compiled JavaScript on the stack:
    at lineTotal (/$bunfs/root/app.ts:17:17)
    at total (/$bunfs/root/app.ts:11:43)
    at checkout (/$bunfs/root/app.ts:23:23)

From a crash to a line of your source#

You need what the process wrote to stderr. You don't need a core file, a debugger or a source map.

Find the report

Search the log for Bun has crashed. The report starts at the line of = above it and ends after the lines that start with at.

Find the build

The line Build ID: names the build. It's the ID that bun compile printed when you built that executable. To ask an executable for its ID, run BUN_OPTIONS=--aot-build-id ./app. Check out the sources of that build: the lines and columns below are theirs.

Read the function and the line

Under Compiled JavaScript on the stack:, the first line is the function that was running, and each line below it is that function's caller. Drop /$bunfs/root/ from a path to get the path of the file, relative to the directory where you ran the build.

If the log only has the link

Pass the link to the executable with that build ID. It prints the same lines and exits without running your program.

terminal
BUN_OPTIONS='--aot-decode-crash-report=https://bun.report/1.4.4/l_24eaedc6E…' ./app

Report it

A crash is a bug in Bun. Open an issue with the report. The function that the first line names is the place to start when you cut your program down to a small one that still crashes.

How to read the lines:

  • Every line but the first is a call. It has the line and column of the call that hadn't returned yet, as in a stack trace.
  • The first line is the last call or check that the function reached before the crash. If the crash happened in a function of Bun that your function called, it's that call. If it happened in the compiled code of your function, the statement that crashed is at that place or soon after it in the same function.
  • Inlined functions have lines of their own, as in a stack trace.
  • An async function is on the stack while it runs. The functions that wait for it with await aren't on the stack, so they aren't listed.
  • The report follows 20 frames of machine code, the frames of Bun included. A deeper stack is cut off at its outer end.
  • With an external source map, a line is only a line and a column in the map, such as at 4:2. The script in that section restores it.
  • Segmentation fault at address is followed by the address that the program tried to read or write, not by a place in your program.

The lines with your names and paths only go to stderr. The link has offsets into your executable and the first 16 digits of its build ID, and none of your names or paths.

Only the build that produced a link can read it, so keep each executable that you deploy. Any other build prints the start of both build IDs and exits with code 1.

Build ID#

Every executable has a build ID: 32 hexadecimal digits made from your program, the files and options that are built into it, and the build of Bun that compiled it. The same sources, the same options and the same Bun give the same ID, on any machine and in any folder, and renaming the file doesn't change it. bun compile prints it.

 [551ms]  bundle  1 modules
 [264ms] compile  app
        build id  5f0c2a9e41d7b3860e9d4c17a2f3b658

A crash report has it near the top, in the line Build ID:, so you can tell which of your builds crashed. To read it from an executable:

terminal
BUN_OPTIONS=--aot-build-id ./app   # prints the ID and exits without running your program
readelf -n ./app                   # on Linux: the note whose owner is Bun

llvm-strip keeps it. Bun doesn't compare it with anything when the executable starts.

On Linux, the last 4 bytes of the executable's GNU build ID are the first 4 bytes of this ID. The other 16 are those of Bun. So perf, gdb and coredumpctl, which tell files apart by the GNU build ID, don't mix up two of your builds.

Don't run GNU strip on an executable that Bun built, with or without --aot. Afterwards it no longer finds your program, and it starts as bun itself.

When the build crashes or hangs#

If the compiler itself crashes, the crash report has a line that says what it was compiling: the function, the file and line where the function starts, and the step of the compiler.

panic: abort() called
Stopped while compiling lineTotal (app.ts:14) in PlaceTypeTests.
To build without that step, add --aot-disable=pass:PlaceTypeTests.

This is a bug in Bun. Open an issue with the report and, if you can share it, the function. If the report names an option, you can build with it until the bug is fixed. In Bun.build, the option is compile: { aotDisable: ["pass:PlaceTypeTests"] }. The executable behaves the same and may be slower. These lines only go to stderr. The link in the report has none of your names or paths.

Every build also checks what the whole-program analysis found. The compiler treats the code after an operation as unreachable when the analysis finds no value for the operation's result. That's right for a call that always throws or a read from null. If every operand has a value, the result has none and the analysis knows no reason, it has lost a value, and the build stops instead of dropping the code.

error: internal compiler error (AnalyzeFlow): OperationWithoutValue: every operand of an operation that is reached has a value, its result has none, and nothing says why it should never complete; function 'lineTotal' (f12), value v7 (PropGet) in function 'lineTotal' at app.ts:16:21
note: This is a bug in Bun. Please report it at https://github.com/oven-sh/bun/issues

The error names the operation and where it's written. Until the bug is fixed, --aot-disable=pass:AnalyzeFlow builds without the analysis.

If a build seems stuck, press Ctrl+C, or end it with kill or timeout. Before it exits, it prints what each thread was working on and for how long.

Still compiling lineTotal (app.ts:14) in PlaceTypeTests for 312 s.

Profiles#

Pass the profiling flags to an executable through BUN_OPTIONS.

terminal
BUN_OPTIONS="--cpu-prof" ./app      # a .cpuprofile for Chrome DevTools or VS Code
BUN_OPTIONS="--cpu-prof-md" ./app   # the same as Markdown
BUN_OPTIONS="--heap-prof" ./app     # a heap snapshot
  • CPU profiles show your functions and your source files by name. Time spent in a function that the compiler inlined counts for its caller.
  • Heap snapshots show objects under the names of their classes, with the names of their properties. Bun.generateHeapSnapshot() and v8.writeHeapSnapshot() work too.
  • process.memoryUsage(), heapStats() from bun:jsc and node:perf_hooks work as usual.

On Linux, --aot-symbols adds your functions to the symbol table of the executable, and unwind information for them. perf and gdb then show them by name. The file gets bigger, and the program uses no more memory. llvm-strip takes the symbols out again.

terminal
bun compile --aot-symbols ./app.ts
perf record -g ./app && perf report
gdb ./app -ex 'break double' -ex run

The names have limits:

  • gdb finds your functions in a program that it starts itself. In a process that it attaches to, and in a core file, it shows addresses. The executable loads its compiled code at a random address, and gdb looks for it where the file says. perf isn't affected.
  • On x64, many compiled functions don't keep a frame pointer. A call stack in perf or gdb skips the callers of such a function.
  • There are no line numbers, so perf annotate and addr2line don't show your source.

Without that flag, perf, gdb, Instruments, sample and lldb show addresses, not names, where your compiled code runs. Use --cpu-prof.

The inspector#

BUN_INSPECT starts the inspector in an executable that was built with --compile-inspector, and VS Code and debug.bun.sh connect to it.

terminal
bun compile --compile-inspector ./app.ts
BUN_INSPECT="ws://127.0.0.1:6499/app" ./app

An inspector runs whatever you type into its console, which is code that isn't in the executable. Any other executable prints an error and exits.

error: BUN_INSPECT can't be used with this executable. Rebuild it with --compile-inspector

The flag makes the executable larger and slower, so build without it for production. Options that load code has the details.

WorksDoesn't work
The console, with objects you can openBreakpoints
Evaluating expressions, calling your functions, reading your objectsStepping and pausing
Heap snapshots and garbage collectiondebugger statements
The CPU timelineThe list of sources

To use breakpoints, run your program from its source files with bun --inspect-brk ./app.ts. To find out whether a bug only shows in compiled code, compare that with the executable.

Instrumentation#

AsyncLocalStorage, AsyncResource, node:diagnostics_channel and tracing channels work as usual. Libraries that trace by patching functions, such as OpenTelemetry's instrumentations, can patch some functions and not others.

WhatPatching it
What Bun and Node.js provide: fetch, setTimeout, console.*, Bun.serve, fs.readFileSync, http.requestWorks
Classes and exports of JavaScript packages: pg.Client.prototype.queryWorks
Methods of your own classes: Db.prototype.queryWorks
Built-in objects: Promise.prototype.then, JSON.parse, Response.prototype.jsonThrows a TypeError

In an executable, as in any bundle, your modules are resolved at build time. Module._load, Module.prototype.require and module.register only see built-in modules such as node:os.