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
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:
- Get
bun check --aotandbun test --aotto pass. - Ship.
- If it's slower than you expected, read
bun compile --explain, then--explain=guards, then run with--aot-log-type-mismatchesunder real traffic.
The examples use this program.
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);bun app.ts6
31Run 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.
- Before anything runs, Bun checks the program as
bun compiledoes. A type error, or anything else that stops the build, is printed as the build prints it, and the process exits with code1. - Built-in objects are read-only, as in an executable.
Array.prototype.push = minethrows the sameTypeErrorwith the same message.
bun run --aot app.ts6
31The 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
.wasmmodule 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--preloadfile stops the run, and so does any error under--parallel. - Mocks work.
mock.module()andspyOn()on the exports of a module do what they do inbun test. An executable has no support for either. There they throwTypeError: mock(module, fn) cannot replace a module that was compiled ahead of timeandTypeError: Cannot spy on 'price' because module exports can't be replaced in a compiled executable. spyOn()works on built-in objects too, as inspyOn(String.prototype, "normalize"), andmockRestore()puts the original back. An assignment to the same member still throws.bun test --aotends with a list of the writes to read-only built-ins that failed, whether or not something caught theTypeError: what was written, where, and which test file was running. Code that swallows such an error may have left its work half done. Under--parallelthere is no list.--rerun-each,--parallel,--isolateand--coveragework as inbun test.--inspect,--inspect-wait,--inspect-brkandBUN_INSPECTwork. The debugger starts after the check, in the program that runs, so breakpoints bind as inbun run.--watchworks. When a file changes, Bun checks the program again and then restarts it. It watches every file the check reads outside ofnode_modules, so a file that has only types and yourtsconfig.jsoncount too. If the check fails, Bun prints the errors and waits for the next change.--hotrestarts the process like--watch, so that Bun checks the code again before it runs.--hot --checkdoes the same.-eand-pcan't be combined with--aot. Bun prints an error and exits.--aottakes a file, not apackage.jsonscript. 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.
| Command | Finds | Your program |
|---|---|---|
bun check --aot --explain=guards | Every guard that can fail, from your source code | Doesn't run |
--aot-log-type-mismatches on bun run, bun test, bun compile | The guards that do fail, with your data | Is 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.
bun check --aot --explain=guards app.tsnote: 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 functionline. - 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.
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| Command | Use it for |
|---|---|
bun run --aot-log-type-mismatches | One run on your machine. Bun compiles the program, runs the machine code and writes no executable |
bun test --aot-log-type-mismatches | The data in your tests |
bun compile --aot-log-type-mismatches | Real 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=guardssays 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
stringoruntyped 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
Workercounts 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.
rm -f mismatches.jsonl
BUN_AOT_TYPE_MISMATCH_LOG=mismatches.jsonl bun test --aot-log-type-mismatches && test ! -s mismatches.jsonlWebAssembly 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 says | Cause |
|---|---|
no-guard-pages | The instance got a memory without guard pages, such as one with maximum: 0. Other instances of the module still run compiled code |
no-simd | The 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| Part | Meaning |
|---|---|
772073ca2bba | The guard's id. It's the same in --explain=guards and in the log, from run to run, until the line moves |
argument | The 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 |
continued | The program continued with the value, as it does under bun run |
count 1 | How often this guard has failed with this kind of value |
from a.ts:41 < b.ts:12 | The callers, nearest first, at most three |
total | The 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.
| Kind | The guard is on |
|---|---|
argument | A parameter |
this | this in a method |
capture | A variable that a closure captures |
return | A function's return value |
field | A field of a class or an object type, static fields included |
variable | A variable |
element | An element of an array or a Set, a key or a value of a Map, the value of a promise |
call-result | The result of a call |
property | A property read |
cast | An as cast |
unknown | Nothing 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.
class Repo {
find(id: number): string {
if (id < 0) throw new RangeError("bad id " + id);
return "x";
}
}
new Repo().find(-1);bun compile ./repo.ts && ./repoRangeError: 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 --aotruns your files where they are, so it prints the lines above the message, asbun rundoes. - Inlined functions keep their frames.
- Some built-in functions have no frame. Compiled code reaches built-ins such as
Array.prototype.pushandevalwithout a call frame, so a trace can lack theat push (native)line thatbun runprints. - A frame has the name its function had at build time. Redefining the
nameproperty 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 underbun 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), andclass 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. Underbun runit has a line of the module as it's built into Bun. - An executable can have a frame that
bun runlacks. The transpiler ofbun runturnsreturn new Error("…")into a call in tail position, and a function leaves the stack before such a call. An executable keeps thenew, so the function that made the error is the first frame. Error.captureStackTrace,Error.prepareStackTrace,Error.stackTraceLimit,console.trace, async frames,uncaughtExceptionandunhandledRejectionwork 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.
class Repo {
find(id: number): string {
if (id < 0) throw new RangeError("bad id " + id);
return "x";
}
}
new Repo().find(-1);bun compile --minify --sourcemap=external ./repo.ts && ./repoThe 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'sB:\~BUN\root\compiled.js. - The
nameof 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--minifygives it. - The map has the names as they are in your sources, with
--minifytoo. - 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.
| File | What it is |
|---|---|
repo.map | The 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.js | Two comments: the name of the map and its debug ID. Tools that upload source maps expect a JavaScript file next to each 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" }]]
}debugIdis the build ID of the executable, with the hyphens of a UUID.debug_idis the spelling that older tools read.sourcesContenthas 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_sourceshas 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.maphas the sourcenative, and a line that means nothing.
Any library that reads source maps can restore a trace. This script uses source-map-js.
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()));./repo 2>&1 | bun restore.ts repo.mapIt 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:12Use 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
bun compile --minify --sourcemap=external ./src/index.ts --outfile dist/appUpload the map
Pass the folder with app.map and app.map.js to sentry-cli. It skips the executable.
sentry-cli sourcemaps upload --org my-org --project my-project distDon'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._debugIdsorglobalThis._sentryDebugIds, depending on its version. An executable with an external source map has both. Each has one entry, which pairscompiled.jswith 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 toSentry.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.
BUN_OPTIONS='--aot-decode-crash-report=https://bun.report/1.4.4/l_24eaedc6E…' ./appReport 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
asyncfunction is on the stack while it runs. The functions that wait for it withawaitaren'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 addressis 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 5f0c2a9e41d7b3860e9d4c17a2f3b658A 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:
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 Bunllvm-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/issuesThe 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.
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()andv8.writeHeapSnapshot()work too. process.memoryUsage(),heapStats()frombun:jscandnode:perf_hookswork 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.
bun compile --aot-symbols ./app.ts
perf record -g ./app && perf report
gdb ./app -ex 'break double' -ex runThe names have limits:
gdbfinds 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, andgdblooks for it where the file says.perfisn't affected.- On x64, many compiled functions don't keep a frame pointer. A call stack in
perforgdbskips the callers of such a function. - There are no line numbers, so
perf annotateandaddr2linedon'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.
bun compile --compile-inspector ./app.ts
BUN_INSPECT="ws://127.0.0.1:6499/app" ./appAn 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-inspectorThe flag makes the executable larger and slower, so build without it for production. Options that load code has the details.
| Works | Doesn't work |
|---|---|
| The console, with objects you can open | Breakpoints |
| Evaluating expressions, calling your functions, reading your objects | Stepping and pausing |
| Heap snapshots and garbage collection | debugger statements |
| The CPU timeline | The 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.
| What | Patching it |
|---|---|
What Bun and Node.js provide: fetch, setTimeout, console.*, Bun.serve, fs.readFileSync, http.request | Works |
Classes and exports of JavaScript packages: pg.Client.prototype.query | Works |
Methods of your own classes: Db.prototype.query | Works |
Built-in objects: Promise.prototype.then, JSON.parse, Response.prototype.json | Throws 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.