diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index 77cb6a6ba3c4..a99232595f95 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -345,6 +345,14 @@ Bytecode compilation moves parsing overhead for large input files from runtime t Bytecode compilation supports both `cjs` and `esm` formats when used with `--compile`. +### Startup snapshots (experimental) + +`--snapshot` goes one step further than bytecode: after producing the executable, `bun build` runs it once and embeds a snapshot of its started-up state — modules evaluated, startup objects built, JIT code included — so later launches resume from that point instead of booting. See [Startup Snapshots](/bundler/startup-snapshots). + +```bash icon="terminal" terminal +bun build --compile --bytecode --snapshot ./app.ts --outfile myapp +``` + ### What do these flags do? The `--minify` argument reduces the size of the transpiled output code. For a large application, this can save megabytes of space. For smaller applications, it might still improve start time a little. diff --git a/docs/bundler/startup-snapshots.mdx b/docs/bundler/startup-snapshots.mdx new file mode 100644 index 000000000000..f78b2ccd0d3d --- /dev/null +++ b/docs/bundler/startup-snapshots.mdx @@ -0,0 +1,159 @@ +--- +title: Startup Snapshots +description: Embed a snapshot of your program's started-up state in a compiled executable, so every launch resumes instead of booting +--- + +A startup snapshot is a build-time optimization for [single-file executables](/bundler/executables). `bun build` runs your executable once, and when it has finished starting up, the runtime writes out the state of the whole process — every module loaded and evaluated, every object your startup code built, JIT-compiled code included — and embeds it in the executable. Every later launch maps that snapshot back into memory and continues from there. The work your program does while starting up happens once, on the build machine, instead of on every launch. + +```bash icon="terminal" terminal +bun build --compile --snapshot ./app.ts --outfile myapp +``` + +This is experimental. It is available on macOS and Linux (glibc). + +## What it saves + +[Bytecode caching](/bundler/bytecode) removes parsing and compiling from startup; the modules still have to be evaluated on every launch, and everything they build — parsers, tables, plugin registries, the object graph a framework assembles before it does anything — is built again each time. A snapshot removes that too: a launch costs what it takes to map a file, and the snapshotted memory is shared between concurrent processes and stays untouched on disk until the program writes to it. + +Whether it is worth it depends on how much your program does before it becomes useful. A tool that imports a large dependency graph, or a long-running program that spends its first second building state, benefits. A small script does not. + +## Command-line tools + +For a program that starts, does one job and exits, the requirement is that the job runs _after_ the snapshot is restored, not while the modules are being loaded. Import everything at the top level and put the program itself in `Bun.startupSnapshot.main()`: + +```ts cli.ts icon="/icons/typescript.svg" +import { format } from "./formatter"; // the expensive part: this, and everything it imports, ends up in the snapshot + +Bun.startupSnapshot.main(async () => { + // Runs in every launch — after the snapshot has been restored, when there is one — with the + // launch's own argv, cwd, environment and stdio. + const [file] = process.argv.slice(2); + process.stdout.write(await format(file)); +}); +``` + +`main()` calls the function immediately in a launch that has no snapshot, stores it in the launch that takes the snapshot, and calls it after the restore in every launch that resumes from one. Because nothing but imports runs before the snapshot is taken, there is nothing machine-specific in it, and the default build settings need no adjustment: + +```bash icon="terminal" terminal +bun build --compile --bytecode --snapshot ./cli.ts --outfile fmt +``` + +### WebAssembly + +A WebAssembly module compiled and instantiated before the snapshot is taken is in it, compiled code and linear memory included, so a tool built around a large wasm module (a compiler or formatter compiled to wasm) starts without compiling it. Code that JavaScriptCore compiles in the background while the program runs is included too, so exercising the module's main entry point once before the snapshot is taken (with representative input) puts the tiered-up code in the snapshot rather than leaving each launch to produce it again. + +## Long-running programs + +A server or an interactive program keeps running after it starts, so its snapshot is taken at the point where startup is over. By default (`--snapshot`, or `--snapshot=auto`) the runtime decides: it evaluates the entry point, lets whatever that started run to completion, and takes the snapshot once nothing is pending. Timers that are still armed at that point survive, with their remaining time preserved across the restore. Nothing in the program has to change. + +A program that wants to choose the moment itself — for example because it wants to release things right before, or because its startup involves timers — builds with `--snapshot=manual` and calls `take()` when it is ready: + +```ts app.ts icon="/icons/typescript.svg" +import { boot } from "./app"; + +const app = await boot(); + +process.on("restore", () => { + // Runs first thing in every launch that resumes from the snapshot, before the first event-loop + // turn: re-read whatever depends on the machine, re-open what has to be open. + app.reload(); +}); + +// Returns immediately in every process except the one the build runs to take the snapshot. +Bun.startupSnapshot.take({ + timers: "keep", // or "cancel"; by default a manual snapshot refuses while timers are armed + envGate: ["APP_MODE"], // launches whose values for these differ from the build's boot normally +}); +``` + +`process.on("restore")` is available in both modes. Its listeners run before anything else in a restored launch, so state that must be recomputed per machine can be recomputed there; `main()` functions run after them. + +## What a restored launch gets from its environment + +The runtime refreshes what it owns before your code runs again: `process.argv`, `process.env`, the working directory, `process.pid`, the clocks (`performance.now()`, `process.uptime()`), the time zone, every source of randomness, timers, the DNS cache, its own threads, standard input and output, which are the launcher's, the IPC channel to a parent that spawned the launch with one (`process.send`), and the defaults it derives from the environment, such as the default S3 client and TLS verification. Files and sockets that were open when the snapshot was taken are gone; the program receives hangups for them after `"restore"`. + +What it cannot refresh is anything your code derived from those before the snapshot was taken: + +- A value read from `process.env`, `os.homedir()`, `process.cwd()` and the like and kept in a variable is the build machine's value in every launch. Holding a reference to `process.env` itself is fine — it is updated in place — but a copy of it (`{ ...process.env }`, `dotenv`-style merging into another object) is not. +- Random values and timestamps produced before the snapshot (an id generated at startup, `Date.now()` stored in a variable) are identical in every launch. +- Anything computed from files or the network before the snapshot is whatever the build machine had. +- `.env` files are read when the snapshot is built, not at launch: a launch gets the build's `.env` values (the launch's own environment still takes precedence), and any `${VAR}` references in them were expanded against the build's environment. +- The JavaScript engine's own options (`BUN_JSC_*` variables) are read once when the engine starts, so a snapshot carries the build's; setting them when launching a snapshot has no effect. + +When the snapshot is written, the build prints which environment variables were read by name before it, and every place `process.env` was copied, with the code that did it. Each entry is either something to move into `main()` or a `"restore"` listener, or a variable that genuinely changes what the program is — those go in `envGate`, and a launch whose value differs boots normally. + +## What the build may do + +While the snapshot is being taken, operations whose result would be frozen into every launch are refused: `fetch()` and other network use, `node:fs`, subprocesses, DNS and sockets throw an error saying so. If the program fails because of that, or never becomes quiet, no snapshot is taken and the build fails, saying what happened; the executable it built is left in place, so it can still be used or the step re-run with different settings. + +Programs that legitimately read their own files or run helpers while starting up can be allowed to: + +| Setting | Allowed while the snapshot is taken | +| -------------------------------- | ---------------------------------------------------------------- | +| `--snapshot-io=strict` (default) | Nothing that touches the machine. | +| `--snapshot-io=local` | Files, subprocesses, local sockets, name resolution. | +| `--snapshot-io=network` | The above, and the network. What it answered is in the snapshot. | + +With either of the last two, the build prints every operation it allowed, attributed to the code that performed it, so what went into the snapshot can be reviewed. + +## Building in CI + +The snapshot has to be taken on the platform the executable is for, because taking it means running the executable. The snapshot step therefore also works by itself, on an executable built earlier, and modifies it in place; running it again replaces the previous snapshot. + +```bash icon="terminal" terminal +# On any machine: build for each target +bun build --compile --target=bun-linux-x64 ./app.ts --outfile dist/app-linux-x64 +bun build --compile --target=bun-darwin-arm64 ./app.ts --outfile dist/app-darwin-arm64 + +# On a Linux x64 machine, and on an Apple silicon machine respectively: +bun build --snapshot --outfile dist/app-linux-x64 +bun build --snapshot=manual --snapshot-io=local --outfile dist/app-darwin-arm64 +``` + +Take snapshots in an environment that holds nothing you would not ship: the snapshot is a copy of the process's memory, and whatever the build environment exposed to the program — variables, files, credentials — is in it. + +## `Bun.build()` + +The programmatic API takes the same thing as a top-level option. It requires `compile`, and the executable is built for the machine running the build. + +```ts build.ts icon="/icons/typescript.svg" +await Bun.build({ + entrypoints: ["./app.ts"], + compile: { outfile: "./myapp" }, + snapshot: true, // or { mode: "auto" | "manual", io: "strict" | "local" | "network" } +}); +``` + +## When a launch does not use its snapshot + +A launch uses its snapshot only when it can do so exactly: the same executable, the same operating system build and system libraries, a CPU with the features the snapshot was taken on, and agreement on every `envGate` variable. Otherwise it boots normally — the program behaves the same either way, it just starts up the slow way. `Bun.startupSnapshot.epoch()` returns `0` in a launch that booted normally and a positive number in one that resumed. + +The snapshot is embedded in the executable as it is, and a launch maps the executable's own pages: nothing is unpacked, and nothing is written anywhere. It makes the executable larger by the size of the snapshotted state — typically tens of megabytes for a tool that loads a large dependency graph — and the build prints the size. For debugging, a launch prefers a `.snapshot` file placed next to the executable over the embedded one, so an alternative snapshot can be tried without re-embedding it. + +## Limitations + +- A launch that resumes from a snapshot runs with address-space layout randomization disabled, because the snapshot has to be mapped at the addresses it was taken at. +- Native addons and `bun:ffi` libraries that keep state of their own, and worker threads that are alive when the snapshot is taken, are not supported. +- macOS and glibc Linux only; on other platforms and builds `--snapshot` reports that it is unavailable. +- Programs that were not written with the snapshot in mind may hold state of the kinds described above; the reports the build prints are the way to find it. + +## Reference + +**`bun build`** + +| Flag | Meaning | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--snapshot`, `--snapshot=auto` | With `--compile`: run the executable once and embed its snapshot, taken when startup has drained. Without entrypoints: do this to the existing `--outfile`. | +| `--snapshot=manual` | The same, but the program calls `Bun.startupSnapshot.take()` to say when. | +| `--snapshot-io=strict\|local\|network` | What the program may do while the snapshot is taken (default `strict`). | + +**`Bun.startupSnapshot`** + +| | | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `main(fn)` | The program. Called now in an ordinary launch, after the restore in a launch that resumes from a snapshot, and not at all in the run that takes the snapshot. | +| `take(options?)` | Manual mode: take the snapshot now. `options.timers` is `"keep"` or `"cancel"`; `options.envGate` is a list of variable names. Returns immediately in every other process. | +| `isBuildingSnapshot()` | `true` only in the run that takes the snapshot. | +| `epoch()` | `0` in a launch that booted normally; otherwise the number of times this process has been resumed. | +| `reclean()` | In a resumed launch, hand pages the program wrote and then restored to their original contents back to the shared snapshot. Optional; useful for programs that idle for a long time. | +| `process.on("restore", fn)` | Runs first in every launch that resumes from a snapshot. | diff --git a/docs/docs.json b/docs/docs.json index 54f3545a1c1c..f20ec1cb34ff 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -251,7 +251,7 @@ { "group": "Single File Executable", "icon": "binary", - "pages": ["/bundler/executables"] + "pages": ["/bundler/executables", "/bundler/startup-snapshots"] }, { "group": "Extensions", diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 9a3cc0e1a680..347130d668b2 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3222,6 +3222,15 @@ declare module "bun" { * ``` */ compile?: boolean | Bun.Build.CompileTarget | CompileBuildOptions; + /** + * Snapshots (experimental; requires `compile`): after producing the executable, run it once and + * embed a snapshot of its started-up state, so later launches resume instead of booting. `true` + * takes the snapshot once startup work drains (`bun build --snapshot`); use `mode: "manual"` when + * the app calls `Bun.startupSnapshot.take()` itself, and `io` to let the build touch this machine + * (`"local"`: files, subprocesses, local sockets; `"network"`: the network too) — every use is + * reported when the snapshot is written. Default `io` is `"strict"`. + */ + snapshot?: boolean | { mode?: "auto" | "manual"; io?: "strict" | "local" | "network" }; } interface CompileBuildOptions { diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index f831004600a9..b02abe64d68c 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -4604,6 +4604,11 @@ impl SpawnStatus { pub fn is_ok(self) -> bool { self.code == 0 } + /// Exit status as the spawner reports it; -1 when the child died of a signal (or, on Windows, no code was available). + #[inline] + pub fn code(self) -> i32 { + self.code + } } // ── posix_spawn_bun FFI (canonical #[repr(C)] mirror) ───────────────────── diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index 9d231ec56e0a..0083e1b0505b 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -208,15 +208,17 @@ impl ElfFile { /// middle of a `PT_LOAD` segment — sections like `.dynamic`, `.got`, /// `.got.plt` come after it, and expanding in-place would invalidate their /// absolute virtual addresses. + /// + /// Re-injecting into our own earlier output works too: every block ends with the `BUN_COMPILED` slot's vaddr, so a rewrite finds the previous block and replaces it: in place when the new payload fits, otherwise at the same address with the segment regrown, so a file never carries more than one block. pub fn write_bun_section(&mut self, payload: &[u8]) -> Result<(), ElfError> { let ehdr = read_ehdr(&self.data); let bun_section = self.find_bun_section(ehdr)?; - let bun_section_offset = bun_section.file_offset; let bun_section_vaddr = bun_section.vaddr; let page_size = Self::page_size(ehdr); let header_size: u64 = size_of::() as u64; - let new_content_size: u64 = header_size + payload.len() as u64; + let trailer_size: u64 = size_of::() as u64; + let new_content_size: u64 = header_size + payload.len() as u64 + trailer_size; let aligned_new_size = align_up(new_content_size, page_size); // Extend the writable PT_LOAD that contains `.bun` (matched by vaddr, @@ -253,6 +255,20 @@ impl ElfFile { return Err(ElfError::NoWritableLoadSegment); }; + let previous_block = self.previous_block_slot(&bun_section, &rw_phdr); + let compiled_slot_vaddr = previous_block.unwrap_or(bun_section.vaddr); + if previous_block.is_some() && new_content_size <= bun_section.size { + // Rewriting our own earlier output with something that fits: overwrite the block in place (the slot already + // points at it; the trailer stays at the end of the block's capacity). + let start = usize::try_from(bun_section.file_offset).expect("int cast"); + let cap = usize::try_from(bun_section.size).expect("int cast"); + write_u64_le(&mut self.data[start..][..8], payload.len() as u64); + self.data[start + 8..][..payload.len()].copy_from_slice(payload); + self.data[start + 8 + payload.len()..start + cap - 8].fill(0); + write_u64_le(&mut self.data[start + cap - 8..][..8], compiled_slot_vaddr); + return Ok(()); + } + // Place the new data at a page-aligned virtual address past every // existing mapping. page_size is ≥ 128 so this also guarantees the // 128-byte alignment that JSC's bytecode cache requires — see @@ -266,7 +282,13 @@ impl ElfFile { // `new_file_offset` follows the segment's existing (vaddr - offset) // delta, so the kernel's mmap at `rw_phdr.p_offset → rw_phdr.p_vaddr` // covers our new payload continuously once we grow p_filesz. - let new_vaddr = align_up(max_vaddr_end, page_size); + // A superseded block of ours is the last thing in the segment: the new one takes its place instead of + // accumulating behind it, so a file rewritten any number of times carries exactly one block. + let new_vaddr = if previous_block.is_some() { + bun_section.vaddr + } else { + align_up(max_vaddr_end, page_size) + }; let offset_in_segment = new_vaddr - rw_phdr.p_vaddr; let new_file_offset = rw_phdr.p_offset + offset_in_segment; @@ -274,7 +296,7 @@ impl ElfFile { // memsz range (the loop above folds every PT_LOAD), so new_vaddr is // past it by construction. This guard catches pathological inputs // (e.g. corrupt ELF with rw_phdr.p_vaddr past max_vaddr_end). - if new_vaddr < rw_phdr.p_vaddr + rw_phdr.p_memsz { + if previous_block.is_none() && new_vaddr < rw_phdr.p_vaddr + rw_phdr.p_memsz { return Err(ElfError::NewVaddrCollides); } @@ -334,9 +356,11 @@ impl ElfFile { // Zero the bytes between the old RW file-content end and the payload // start. This entire range is now inside the extended PT_LOAD's // file-backed region; keeping it zero preserves BSS semantics. - self.data[usize::try_from(move_src_start).expect("int cast") - ..usize::try_from(new_file_offset).expect("int cast")] - .fill(0); + if new_file_offset > move_src_start { + self.data[usize::try_from(move_src_start).expect("int cast") + ..usize::try_from(new_file_offset).expect("int cast")] + .fill(0); + } // Write the payload at the new location: [u64 LE size][data][zero padding] write_u64_le( @@ -348,19 +372,24 @@ impl ElfFile { .copy_from_slice(payload); // Zero the padding between payload end and the relocated tail - let payload_end = new_file_offset + new_content_size; + let payload_end = new_file_offset + header_size + payload.len() as u64; // the trailer is written separately at the end of the block if move_dst_start > payload_end { self.data[usize::try_from(payload_end).expect("int cast") ..usize::try_from(move_dst_start).expect("int cast")] .fill(0); } - // Write the vaddr of the appended data at the ORIGINAL .bun section location - // (where BUN_COMPILED symbol points). At runtime, BUN_COMPILED.size will be - // this vaddr (always non-zero), which the runtime dereferences as a pointer. - // Non-standalone binaries have BUN_COMPILED.size = 0, so 0 means "no data". + // The block's trailer: where the `BUN_COMPILED` slot is, for the next rewrite of this file. write_u64_le( - &mut self.data[usize::try_from(bun_section_offset).expect("int cast")..][..8], + &mut self.data[usize::try_from(new_file_offset + aligned_new_size - trailer_size) + .expect("int cast")..][..8], + compiled_slot_vaddr, + ); + + // The runtime reads BUN_COMPILED.size as the payload's vaddr (0 = no payload); the slot sits at a fixed offset into the RW segment in every rewrite of this file. + let compiled_slot_offset = rw_phdr.p_offset + (compiled_slot_vaddr - rw_phdr.p_vaddr); + write_u64_le( + &mut self.data[usize::try_from(compiled_slot_offset).expect("int cast")..][..8], new_vaddr, ); @@ -388,7 +417,7 @@ impl ElfFile { if i == bun_section.section_index as usize { shdr.sh_offset = new_file_offset; - shdr.sh_size = new_content_size; + shdr.sh_size = aligned_new_size; shdr.sh_addr = new_vaddr; } else if shdr.sh_type != SHT_NOBITS && shdr.sh_offset >= move_src_start @@ -432,6 +461,28 @@ impl ElfFile { // --- Internal helpers --- + /// If the `.bun` header describes a block this function wrote earlier, the `BUN_COMPILED` slot recorded in its + /// trailer (validated: inside the RW segment and currently pointing at this block); `None` for a clean template. + fn previous_block_slot( + &self, + bun_section: &BunSectionInfo, + rw_phdr: &Elf64_Phdr, + ) -> Option { + let word = size_of::() as u64; + if bun_section.size < 2 * word { + return None; + } + let trailer_off = + usize::try_from(bun_section.file_offset + bun_section.size - word).ok()?; + let slot_vaddr = read_u64_le(self.data.get(trailer_off..trailer_off + 8)?); + if slot_vaddr < rw_phdr.p_vaddr || slot_vaddr + word > rw_phdr.p_vaddr + rw_phdr.p_filesz { + return None; + } + let slot_off = usize::try_from(rw_phdr.p_offset + (slot_vaddr - rw_phdr.p_vaddr)).ok()?; + (read_u64_le(self.data.get(slot_off..slot_off + 8)?) == bun_section.vaddr) + .then_some(slot_vaddr) + } + /// Returns the file offset and section index of the `.bun` section. fn find_bun_section(&self, ehdr: Elf64_Ehdr) -> Result { let shdr_size = size_of::(); @@ -467,6 +518,7 @@ impl ElfFile { return Ok(BunSectionInfo { file_offset: shdr.sh_offset, vaddr: shdr.sh_addr, + size: shdr.sh_size, section_index: u16::try_from(i).expect("int cast"), }); } @@ -501,6 +553,8 @@ struct BunSectionInfo { file_offset: u64, /// Virtual address of the .bun section (sh_addr). vaddr: u64, + /// Size of whatever the header currently describes (sh_size). + size: u64, /// Index of the .bun section in the section header table. section_index: u16, } @@ -735,6 +789,10 @@ pub(crate) struct Elf64_Shdr { // --- byte helpers --- +fn read_u64_le(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes[..8].try_into().expect("8 bytes")) +} + #[inline] fn write_u64_le(bytes: &mut [u8], value: u64) { bytes[..8].copy_from_slice(&value.to_le_bytes()); diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 4190731b8ed8..8d4ab86d57e7 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -183,6 +183,29 @@ impl ContextData { // (`bun_runtime::cli::command::create_context_data`), which depends on this // crate — a delegating fn here would invert the dependency. +/// `--snapshot` / `snapshot: true | { mode }` in `Bun.build`: whether `bun build --compile` also runs the executable once and embeds a snapshot of it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CompileStartupSnapshot { + #[default] + Off, + /// The runtime snapshots the process itself once startup work has drained; the app needs no code for this. + Auto, + /// The app decides when, by calling `Bun.startupSnapshot.take()`. + Manual, +} + +/// `--snapshot-io` / `snapshot: { io }` in `Bun.build`: what the app may touch on the build machine while its snapshot is taken. +/// `strict` refuses all of it, `local` allows the file system and processes, `network` allows sockets and DNS too. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CompileStartupSnapshotIo { + #[default] + Strict, + /// Files, subprocesses, local sockets and the resolver are allowed; every use is listed when the snapshot is written. + Local, + /// Additionally the network: what it answered is frozen into every launch. Every use is listed. + Network, +} + pub struct BundlerOptions { pub outdir: Box<[u8]>, pub outfile: Box<[u8]>, @@ -207,6 +230,8 @@ pub struct BundlerOptions { pub emit_dce_annotations: bool, pub output_format: bundle_enums::Format, pub bytecode: bool, + pub compile_startup_snapshot: CompileStartupSnapshot, + pub compile_startup_snapshot_io: CompileStartupSnapshotIo, pub banner: Box<[u8]>, pub footer: Box<[u8]>, pub css_chunking: bool, @@ -261,6 +286,8 @@ impl Default for BundlerOptions { emit_dce_annotations: true, output_format: bundle_enums::Format::Esm, bytecode: false, + compile_startup_snapshot: CompileStartupSnapshot::Off, + compile_startup_snapshot_io: CompileStartupSnapshotIo::Strict, banner: Box::default(), footer: Box::default(), css_chunking: false, diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index f70ee676d02e..e6c73b2a56db 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -221,6 +221,61 @@ pub mod js_bundler { } } + /// Top-level `snapshot: true | { mode?: "auto" | "manual", io?: "strict" | "local" | "network" }` (`bun build --snapshot`). + fn parse_startup_snapshot_options( + global_this: &JSGlobalObject, + config: JSValue, + this: &mut CompileOptions, + ) -> JsResult<()> { + use bun_options_types::context::{CompileStartupSnapshot, CompileStartupSnapshotIo}; + let Some(value) = config.get_own(global_this, &BunString::static_str("snapshot"))? else { + return Ok(()); + }; + if value.is_boolean() { + this.snapshot = CompileStartupSnapshot::Auto; + return Ok(()); + } + if !value.is_object() { + return Err(global_this.throw_invalid_arguments(format_args!( + "snapshot must be true or an object: {{ mode?: \"auto\" | \"manual\", io?: \"strict\" | \"local\" | \"network\" }}" + ))); + } + this.snapshot = CompileStartupSnapshot::Auto; + if let Some(mode) = value + .get_own(global_this, &BunString::static_str("mode"))? + .filter(|v| !v.is_undefined()) + { + let mode = mode.to_bun_string(global_this)?; + this.snapshot = if mode.eql_comptime("auto") { + CompileStartupSnapshot::Auto + } else if mode.eql_comptime("manual") { + CompileStartupSnapshot::Manual + } else { + return Err(global_this.throw_invalid_arguments(format_args!( + "snapshot.mode must be \"auto\" or \"manual\"" + ))); + }; + } + if let Some(io) = value + .get_own(global_this, &BunString::static_str("io"))? + .filter(|v| !v.is_undefined()) + { + let io = io.to_bun_string(global_this)?; + this.snapshot_io = if io.eql_comptime("strict") { + CompileStartupSnapshotIo::Strict + } else if io.eql_comptime("local") { + CompileStartupSnapshotIo::Local + } else if io.eql_comptime("network") { + CompileStartupSnapshotIo::Network + } else { + return Err(global_this.throw_invalid_arguments(format_args!( + "snapshot.io must be \"strict\", \"local\" or \"network\"" + ))); + }; + } + Ok(()) + } + pub struct CompileOptions { pub(crate) compile_target: CompileTarget, pub(crate) exec_argv: OwnedString, @@ -238,6 +293,8 @@ pub mod js_bundler { pub(crate) autoload_bunfig: bool, pub(crate) autoload_tsconfig: bool, pub(crate) autoload_package_json: bool, + pub(crate) snapshot: bun_options_types::context::CompileStartupSnapshot, + pub(crate) snapshot_io: bun_options_types::context::CompileStartupSnapshotIo, } impl Default for CompileOptions { @@ -259,6 +316,8 @@ pub mod js_bundler { autoload_bunfig: true, autoload_tsconfig: false, autoload_package_json: false, + snapshot: bun_options_types::context::CompileStartupSnapshot::Off, + snapshot_io: bun_options_types::context::CompileStartupSnapshotIo::Strict, } } } @@ -276,12 +335,33 @@ pub mod js_bundler { // errdefer this.deinit() — Drop handles owned fields let object = 'brk: { + let snapshot_requested = config + .get_own(global_this, &BunString::static_str("snapshot"))? + .is_some_and(|v| !v.is_undefined_or_null() && v != JSValue::FALSE); let Some(compile_value) = config.get_truthy(global_this, "compile")? else { - return Ok(None); + if !snapshot_requested { + return Ok(None); + } + // `target: "bun-"` enables compilation without a `compile` key; the snapshot options still apply to it. + if compile_target.is_some() { + parse_startup_snapshot_options(global_this, config, &mut this)?; + return Ok(Some(this)); + } + return Err(global_this.throw_invalid_arguments(format_args!( + "snapshot requires compile: a snapshot is taken of the compiled executable" + ))); }; + if snapshot_requested { + parse_startup_snapshot_options(global_this, config, &mut this)?; + } if compile_value.is_boolean() { if compile_value == JSValue::FALSE { + if snapshot_requested { + return Err(global_this.throw_invalid_arguments(format_args!( + "snapshot requires compile: a snapshot is taken of the compiled executable" + ))); + } return Ok(None); } return Ok(Some(this)); @@ -1303,6 +1383,15 @@ pub mod js_bundler { "Cannot use compile.assets with target 'browser' for standalone HTML" ))); } + if has_all_html + && this.compile.as_ref().is_some_and(|c| { + c.snapshot != bun_options_types::context::CompileStartupSnapshot::Off + }) + { + return Err(global_this.throw_invalid_arguments(format_args!( + "Cannot use snapshot with target 'browser' for standalone HTML: it is not a process to snapshot" + ))); + } } scopeguard::ScopeGuard::into_inner(plugins); diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 27657f0da198..2ca6c17ffddf 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -26,6 +26,7 @@ use bun_io::KeepAlive; use bun_jsc::WorkPool; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue}; use bun_options_types::WindowsOptions; +use bun_options_types::context::CompileStartupSnapshot; use bun_options_types::schema::api; use bun_paths::resolve_path::{join_abs_string, join_abs_string_buf, platform}; use bun_paths::{self as paths, PathBuffer, SEP}; @@ -447,6 +448,7 @@ impl JSBundleCompletionTask { Some(&compile_options.executable_path.list) }, flags, + None, ) { Ok(r) => r, Err(err) => { @@ -454,6 +456,29 @@ impl JSBundleCompletionTask { } }; + if matches!(result, CompileResult::Success) + && compile_options.snapshot != CompileStartupSnapshot::Off + { + if !compile_options.compile_target.is_default() { + return CompileResult::fail_fmt(format_args!( + "snapshot has to run the executable, which a cross-compiled one can't do here; build without it and run `bun build --snapshot --outfile ` on the target platform" + )); + } + match crate::cli::build_command::run_startup_snapshot_step( + root_dir.fd, + outfile_for_executable, + compile_options.snapshot, + compile_options.snapshot_io, + // SAFETY: as above. + unsafe { &mut *self.env }, + ) { + Ok(bytes) => crate::cli::build_command::report_startup_snapshot_step(bytes), + Err(message) => { + return CompileResult::fail_fmt(format_args!("{}", bstr::BStr::new(&message))); + } + } + } + if matches!(result, CompileResult::Success) { let entry = &mut output_files[entry_point_index]; entry.dest_path.clone_from(&full_outfile_path); diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 0ace6e3b893f..817aba6502c0 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -443,6 +443,12 @@ pub(crate) const BUILD_ONLY_PARAMS: &[ParamType] = concat_params!( "--asset ... Embed a file or directory into the compiled executable, preserving its relative path (requires --compile)" ), parse_param!("--bytecode Use a bytecode cache"), + parse_param!( + "--snapshot ? After --compile, run the executable once and embed a snapshot of it, so later launches resume instead of booting. 'auto' (default: taken once startup drains) or 'manual' (the app calls Bun.startupSnapshot.take())" + ), + parse_param!( + "--snapshot-io What the app may touch while its snapshot is taken: 'strict' (default: nothing), 'local' (files, subprocesses, local sockets) or 'network' (that too); every use is reported" + ), parse_param!( "--watch Automatically restart the process on file change" ), @@ -1638,7 +1644,12 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Resultbun build v{}", bun_core::Global::package_json_version_with_sha @@ -2042,6 +2053,41 @@ fn parse_build_command_options( ) { ctx.bundler_options.transform_only = args.flag(b"--no-bundle"); ctx.bundler_options.bytecode = args.flag(b"--bytecode"); + if let Some(mode) = args.option(b"--snapshot") { + ctx.bundler_options.compile_startup_snapshot = match mode { + b"" | b"auto" => bun_options_types::context::CompileStartupSnapshot::Auto, + b"manual" => bun_options_types::context::CompileStartupSnapshot::Manual, + other => { + bun_core::pretty_errorln!( + "error: --snapshot expects 'auto' or 'manual', got \"{}\"", + BStr::new(other) + ); + Global::exit(1); + } + }; + } + if let Some(io) = args.option(b"--snapshot-io") { + if ctx.bundler_options.compile_startup_snapshot + == bun_options_types::context::CompileStartupSnapshot::Off + { + bun_core::pretty_errorln!( + "error: --snapshot-io only applies together with --snapshot" + ); + Global::exit(1); + } + ctx.bundler_options.compile_startup_snapshot_io = match io { + b"strict" => bun_options_types::context::CompileStartupSnapshotIo::Strict, + b"local" => bun_options_types::context::CompileStartupSnapshotIo::Local, + b"network" => bun_options_types::context::CompileStartupSnapshotIo::Network, + other => { + bun_core::pretty_errorln!( + "error: --snapshot-io expects 'strict', 'local' or 'network', got \"{}\"", + BStr::new(other) + ); + Global::exit(1); + } + }; + } let production = args.flag(b"--production"); diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 92003b6f7c64..d9ee2ca70340 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -9,7 +9,7 @@ use bun_core::env::OperatingSystem; use bun_core::strings; use bun_core::{Global, Output, fmt as bun_fmt}; use bun_js_parser::parser::Runtime; -use bun_options_types::context::MacroOptions; +use bun_options_types::context::{CompileStartupSnapshot, CompileStartupSnapshotIo, MacroOptions}; use bun_options_types::schema::api; use bun_paths::{PathBuffer, resolve_path}; use bun_sys::{self, Fd, FdExt as _}; @@ -140,6 +140,56 @@ impl BuildCommand { this_transpiler.options.ignore_module_resolution_errors = true; } + if ctx.bundler_options.compile_startup_snapshot != CompileStartupSnapshot::Off + && ctx.args.entry_points.is_empty() + { + // The snapshot step by itself, on an executable built earlier (possibly cross-compiled elsewhere). + let exe: &[u8] = &ctx.bundler_options.outfile; + if exe.is_empty() { + Output::print_errorln(format_args!( + "--snapshot without entrypoints takes the snapshot of an existing executable: pass it as --outfile" + )); + Global::exit(1); + } + let env_ptr = this_transpiler.env; + let exe_dir = match bun_core::dirname(exe) { + Some(parent) if !parent.is_empty() && parent != b"." => { + match bun_sys::Dir::cwd().open_dir(parent, Default::default()) { + // the executable already exists there; a typo must not create directories + Ok(d) => d, + Err(err) => { + Output::err(err, "could not open {}", (bun_fmt::quote(parent),)); + Global::exit(1); + } + } + } + _ => bun_sys::Dir::cwd(), + }; + match run_startup_snapshot_step( + exe_dir.fd, + exe, + ctx.bundler_options.compile_startup_snapshot, + ctx.bundler_options.compile_startup_snapshot_io, + // SAFETY: `env` is a process-lifetime singleton. + unsafe { &mut *env_ptr }, + ) { + Ok(bytes) => report_startup_snapshot_step(bytes), + Err(message) => { + Output::print_errorln(format_args!("{}", bstr::BStr::new(&message))); + Global::exit(1); + } + } + return Ok(()); + } + if ctx.bundler_options.compile_startup_snapshot != CompileStartupSnapshot::Off + && !ctx.bundler_options.compile + { + Output::print_errorln(format_args!( + "--snapshot needs --compile (or no entrypoints, to take the snapshot of an existing --outfile)" + )); + Global::exit(1); + } + // Note: clone the first entry point so `outfile` can borrow owned // storage instead of `this_transpiler.options.entry_points[0]`, which // would otherwise hold an immutable borrow of `this_transpiler` across @@ -287,6 +337,12 @@ impl BuildCommand { ); Global::exit(1); } + if ctx.bundler_options.compile_startup_snapshot != CompileStartupSnapshot::Off { + bun_core::pretty_errorln!( + "error: cannot use --compile --target browser with --snapshot: a standalone HTML file is not a process to snapshot" + ); + Global::exit(1); + } // This is not a bun executable compile - clear compile flags this_transpiler.options.compile_mode = options::CompileMode::StandaloneHtml; @@ -882,6 +938,23 @@ impl BuildCommand { } } + let compile_flags = { + use bun_standalone_module_graph::StandaloneModuleGraph::Flags; + let mut flags = Flags::default(); + if !ctx.bundler_options.compile_autoload_dotenv { + flags |= Flags::DISABLE_DEFAULT_ENV_FILES; + } + if !ctx.bundler_options.compile_autoload_bunfig { + flags |= Flags::DISABLE_AUTOLOAD_BUNFIG; + } + if !ctx.bundler_options.compile_autoload_tsconfig { + flags |= Flags::DISABLE_AUTOLOAD_TSCONFIG; + } + if !ctx.bundler_options.compile_autoload_package_json { + flags |= Flags::DISABLE_AUTOLOAD_PACKAGE_JSON; + } + flags + }; let result = match bun_standalone_module_graph::StandaloneModuleGraph::to_executable( compile_target, output_files, @@ -897,23 +970,8 @@ impl BuildCommand { .as_deref() .unwrap_or(b""), ctx.bundler_options.compile_executable_path.as_deref(), - { - use bun_standalone_module_graph::StandaloneModuleGraph::Flags; - let mut flags = Flags::default(); - if !ctx.bundler_options.compile_autoload_dotenv { - flags |= Flags::DISABLE_DEFAULT_ENV_FILES; - } - if !ctx.bundler_options.compile_autoload_bunfig { - flags |= Flags::DISABLE_AUTOLOAD_BUNFIG; - } - if !ctx.bundler_options.compile_autoload_tsconfig { - flags |= Flags::DISABLE_AUTOLOAD_TSCONFIG; - } - if !ctx.bundler_options.compile_autoload_package_json { - flags |= Flags::DISABLE_AUTOLOAD_PACKAGE_JSON; - } - flags - }, + compile_flags, + None, ) { Ok(r) => r, Err(err) => { @@ -932,6 +990,29 @@ impl BuildCommand { Global::exit(1); } + if ctx.bundler_options.compile_startup_snapshot != CompileStartupSnapshot::Off { + if is_cross_compile { + Output::print_errorln(format_args!( + "--snapshot has to run the executable, which a cross-compiled one can't do here. Build without it, then run `bun build --snapshot --outfile ` on the target platform." + )); + Global::exit(1); + } + match run_startup_snapshot_step( + root_dir.fd, + outfile, + ctx.bundler_options.compile_startup_snapshot, + ctx.bundler_options.compile_startup_snapshot_io, + // SAFETY: `env` is a process-lifetime singleton. + unsafe { &mut *env_ptr }, + ) { + Ok(bytes) => report_startup_snapshot_step(bytes), + Err(message) => { + Output::print_errorln(format_args!("{}", bstr::BStr::new(&message))); + Global::exit(1); + } + } + } + // Write external sourcemap files next to the compiled executable. // With --splitting, there can be multiple .map files (one per chunk). if opt_source_map == options::SourceMapOption::External { @@ -1420,3 +1501,148 @@ pub(crate) fn collect_compile_assets( } Ok(()) } + +/// The snapshot step: run `exe` (in `dir`) once so it writes its snapshot, then embed that in place; re-running replaces the previous snapshot. +pub(crate) fn run_startup_snapshot_step( + dir: bun_sys::Fd, + exe: &[u8], + mode: CompileStartupSnapshot, + io: CompileStartupSnapshotIo, + env: &mut bun_dotenv::Loader, +) -> Result> { + if !Bun__startupSnapshotSupported() { + return Err(b"startup snapshots are not available in this build of bun (macOS with mimalloc as the process allocator, and glibc Linux)".to_vec()); + } + use bun_standalone_module_graph::StandaloneModuleGraph::{ + CompileResult, Flags, embed_startup_snapshot_into_executable, + set_startup_snapshot_build_flags, + }; + // `dir` is the executable's directory (as for `to_executable`); only the file name of `exe` matters here. + let name = bun_paths::basename(exe); + let exe_abs: Vec = { + let mut buf = bun_paths::PathBuffer::uninit(); + let mut p = match bun_sys::get_fd_path(dir, &mut buf) { + Ok(p) => p.to_vec(), + Err(_) if dir == bun_sys::Fd::cwd() => b".".to_vec(), // AT_FDCWD has no path; "./name" is right + Err(err) => { + return Err(format!( + "could not resolve the output directory to run the executable from: {err}" + ) + .into_bytes()); + } + }; + p.push(b'/'); + p.extend_from_slice(name); + p + }; + let failed = |result: bun_standalone_module_graph::Result, + what: &str| + -> Option> { + match result { + Ok(CompileResult::Err(err)) => Some(err.slice().to_vec()), + Err(err) => Some(format!("{what}: {}", err.name()).into_bytes()), + Ok(_) => None, + } + }; + // The executable learns that (and how) it should take its snapshot from a marking in its payload; its env and argv belong to the app. + let mut marking = Flags::TAKE_STARTUP_SNAPSHOT; + if mode == CompileStartupSnapshot::Manual { + marking |= Flags::STARTUP_SNAPSHOT_MANUAL; + } + match io { + CompileStartupSnapshotIo::Strict => {} + CompileStartupSnapshotIo::Local => marking |= Flags::STARTUP_SNAPSHOT_IO_LOCAL, + CompileStartupSnapshotIo::Network => marking |= Flags::STARTUP_SNAPSHOT_IO_NETWORK, + } + if let Some(message) = failed( + set_startup_snapshot_build_flags(&exe_abs, marking, dir, name, env), + "could not prepare the executable", + ) { + return Err(message); + } + bun_core::prettyln!( + "[snapshot] running {} once to take its snapshot", + bstr::BStr::new(&exe_abs) + ); + Output::flush(); + let mut snapshot_path = exe_abs.clone(); + snapshot_path.extend_from_slice(b".snapshot"); + let snapshot_z = bun_core::ZBox::from_vec_with_nul(snapshot_path.clone()); + let _ = bun_sys::unlink(&snapshot_z); // a sidecar left by an earlier run must not pass for this run's + let status = bun_core::util::spawn_sync_inherit(&[exe_abs.as_slice()]); + let written = bun_sys::stat(&snapshot_z).is_ok(); + let ran_ok = matches!(&status, Ok(st) if st.is_ok()); + if !ran_ok || !written { + // Whatever happened, what is left on disk must be an ordinary executable again. + let _ = set_startup_snapshot_build_flags(&exe_abs, Flags::empty(), dir, name, env); + let _ = bun_sys::unlink(&snapshot_z); + // 70 = the runtime gave up waiting for the app to become quiet (it printed why); anything else non-zero is the app failing. Either way there is no snapshot, so the build fails. + const NOT_QUIET: i32 = 70; + return Err(match status { + Err(e) => format!("could not run {}: {:?}", bstr::BStr::new(&exe_abs), e), + Ok(st) if st.code() == NOT_QUIET => format!( + "{} did not become quiet, so no snapshot was taken (see above for what kept it busy; --snapshot=manual lets the app call Bun.startupSnapshot.take() at a moment of its choosing)", + bstr::BStr::new(&exe_abs) + ), + Ok(st) if st.code() == -1 => format!( + "{} was killed by a signal while its snapshot was being taken (see its output above)", + bstr::BStr::new(&exe_abs) + ), + Ok(st) if !st.is_ok() => format!( + "{} exited with status {} while its snapshot was being taken (see its output above)", + bstr::BStr::new(&exe_abs), + st.code() + ), + Ok(_) if mode == CompileStartupSnapshot::Manual => format!( + "{} exited without taking a snapshot: with --snapshot=manual the app has to call Bun.startupSnapshot.take() before it exits", + bstr::BStr::new(&exe_abs) + ), + Ok(_) => format!( + "{} exited before its startup work drained, so no snapshot was taken: an app that exits on its own cannot be snapshotted in auto mode (--snapshot=manual lets it call Bun.startupSnapshot.take() at the right moment)", + bstr::BStr::new(&exe_abs) + ), + } + .into_bytes()); + } + // The snapshot goes into the executable as it is: a launch maps the executable's own pages; nothing is unpacked anywhere. + let snapshot = bun_sys::File::openat(bun_sys::Fd::cwd(), &snapshot_path, bun_sys::O::RDONLY, 0) + .and_then(|f| f.read_to_end()) + .map_err(|e| { + format!("could not read {}: {}", bstr::BStr::new(&snapshot_path), e).into_bytes() + }); + let embedded = snapshot + .as_ref() + .ok() + .map(|snapshot| embed_startup_snapshot_into_executable(&exe_abs, snapshot, dir, name, env)); + if !bun_core::env_var::BUN_STARTUP_SNAPSHOT_KEEP_SIDECAR + .get() + .unwrap_or(false) + { + let _ = bun_sys::unlink(&snapshot_z); + } + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(message) => { + let _ = set_startup_snapshot_build_flags(&exe_abs, Flags::empty(), dir, name, env); // never leave it in take-a-snapshot mode + return Err(message); + } + }; + let embedded = embedded.expect("embed ran when the snapshot was read"); + if let Some(message) = failed(embedded, "failed to embed the snapshot") { + let _ = set_startup_snapshot_build_flags(&exe_abs, Flags::empty(), dir, name, env); + return Err(message); + } + Ok(snapshot.len()) +} + +unsafe extern "C" { + safe fn Bun__startupSnapshotSupported() -> bool; +} + +pub(crate) fn report_startup_snapshot_step(snapshot_bytes: usize) { + bun_core::prettyln!( + "[snapshot] embedded a {:.1} MB snapshot into the executable", + snapshot_bytes as f64 / 1048576.0 + ); + Output::flush(); +} diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index e7725029e1be..0d23159e9466 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -1901,6 +1901,222 @@ pub(crate) fn download_to_path( Ok(()) } +/// The snapshot holds pointers into exactly these payload bytes, so they are reused verbatim with the snapshot appended page-aligned; `min_len` pads the result to the payload size already in the file, since the Mach-O injector can only grow. +pub fn append_startup_snapshot_to_serialized( + bytes: &[u8], + snapshot: &[u8], + min_len: usize, +) -> Option> { + if bytes.len() < size_of::() + TRAILER.len() + || &bytes[bytes.len() - TRAILER.len()..] != TRAILER + { + return None; + } + let body_len = bytes.len() - size_of::() - TRAILER.len(); + // SAFETY: bounds checked; Offsets is repr(C) POD. + let mut offsets: Offsets = + unsafe { core::ptr::read_unaligned(bytes[body_len..].as_ptr().cast::()) }; + const BLOB_HEADER_BYTES: usize = size_of::(); + let pad = (EMBEDDED_SNAPSHOT_ALIGN + - ((BLOB_HEADER_BYTES + body_len) % EMBEDDED_SNAPSHOT_ALIGN)) + % EMBEDDED_SNAPSHOT_ALIGN; + let mut out = + Vec::with_capacity(body_len + pad + snapshot.len() + size_of::() + TRAILER.len()); + out.extend_from_slice(&bytes[..body_len]); + out.resize(body_len + pad, 0); + offsets.flags.remove(Flags::STARTUP_SNAPSHOT_BUILD_BITS); + // The trailer addresses the payload with 32-bit fields; anything larger would be recorded truncated and mapped wrong. + let (Ok(offset), Ok(length)) = (u32::try_from(body_len + pad), u32::try_from(snapshot.len())) + else { + return None; + }; + offsets.snapshot = StringPointer { offset, length }; + out.extend_from_slice(snapshot); + let tail = size_of::() + TRAILER.len(); + if out.len() + tail < min_len { + out.resize(min_len - tail, 0); + } + offsets.byte_count = out.len(); + // SAFETY: Offsets is repr(C) POD. + out.extend_from_slice(unsafe { + core::slice::from_raw_parts((&raw const offsets).cast::(), size_of::()) + }); + out.extend_from_slice(TRAILER); + Some(out) +} + +/// A compiled executable's payload as it sits in the file: `[body][pad][snapshot]?[Offsets][TRAILER]`. +struct ExecutablePayload { + file: Vec, + payload_start: usize, + offsets_pos: usize, + offsets: Offsets, +} + +fn read_executable_payload(exe_path: &[u8]) -> Result { + let file = bun_sys::File::openat(Fd::cwd(), exe_path, bun_sys::O::RDONLY, 0) + .and_then(|f| f.read_to_end()) + .map_err(|err| { + CompileResult::fail_fmt(format_args!( + "could not read {}: {}", + bstr::BStr::new(exe_path), + err + )) + })?; + // The payload's trailer is the last TRAILER occurrence in the file (the section is the last thing before __LINKEDIT / appended on ELF). + let tpos = bun_core::strings::last_index_of(&file, TRAILER).ok_or_else(|| { + CompileResult::fail_fmt(format_args!( + "{} is not a `bun build --compile` executable", + bstr::BStr::new(exe_path) + )) + })?; + if tpos < size_of::() { + return Err(CompileResult::fail_fmt(format_args!( + "corrupt trailer in {}", + bstr::BStr::new(exe_path) + ))); + } + let offsets_pos = tpos - size_of::(); + // SAFETY: bounds checked; Offsets is repr(C) POD. + let offsets: Offsets = + unsafe { core::ptr::read_unaligned(file[offsets_pos..].as_ptr().cast::()) }; + if offsets.byte_count > offsets_pos { + return Err(CompileResult::fail_fmt(format_args!( + "corrupt payload length in {}", + bstr::BStr::new(exe_path) + ))); + } + Ok(ExecutablePayload { + payload_start: offsets_pos - offsets.byte_count, + file, + offsets_pos, + offsets, + }) +} + +/// Re-emit `exe_path` (used as its own template) with `payload` in place of its current one, through the normal inject/sign path. +fn rewrite_executable( + exe_path: &[u8], + payload: &[u8], + out_dir: Fd, + out_name: &[u8], + env: &mut bun_dotenv::Loader, +) -> crate::Result { + // The file's own format picks the injector: the step may be pointed at another OS's executable, which must survive the (failing) attempt intact. + let mut target = CompileTarget::default(); + let mut magic = [0u8; 4]; + if let Ok(file) = bun_sys::File::openat(Fd::cwd(), exe_path, bun_sys::O::RDONLY, 0) + && file.read(&mut magic).is_ok() + { + target.os = match &magic { + [0x7f, b'E', b'L', b'F'] => CompileTargetOs::Linux, + [b'M', b'Z', ..] => CompileTargetOs::Windows, + _ => CompileTargetOs::Mac, + }; + } + to_executable( + &target, + &[], + out_dir, + b"", + out_name, + env, + Format::Esm, + &WindowsOptions::default(), + b"", + Some(exe_path), + Flags::default(), + Some(payload), + ) +} + +/// Mark (or, with empty `flags`, unmark) the executable so that running it takes its snapshot (`Flags::TAKE_STARTUP_SNAPSHOT`); only the trailer word changes. +pub fn set_startup_snapshot_build_flags( + exe_path: &[u8], + flags: Flags, + out_dir: Fd, + out_name: &[u8], + env: &mut bun_dotenv::Loader, +) -> crate::Result { + let exe = match read_executable_payload(exe_path) { + Ok(exe) => exe, + Err(failure) => return Ok(failure), + }; + let mut offsets = exe.offsets; + offsets.flags.remove(Flags::STARTUP_SNAPSHOT_BUILD_BITS); + offsets + .flags + .insert(flags & Flags::STARTUP_SNAPSHOT_BUILD_BITS); + if offsets.flags == exe.offsets.flags { + return Ok(CompileResult::Success); + } + let mut payload = exe.file + [exe.payload_start..exe.offsets_pos + size_of::() + TRAILER.len()] + .to_vec(); + let rel = exe.offsets_pos - exe.payload_start; + // SAFETY: Offsets is repr(C) POD; `rel..rel+size` is where it was read from. + payload[rel..rel + size_of::()].copy_from_slice(unsafe { + core::slice::from_raw_parts((&raw const offsets).cast::(), size_of::()) + }); + drop(exe); + rewrite_executable(exe_path, &payload, out_dir, out_name, env) +} + +/// Embed a snapshot into an existing compiled executable (replacing any previous one) and clear the `TAKE_STARTUP_SNAPSHOT` marking. +pub fn embed_startup_snapshot_into_executable( + exe_path: &[u8], + snapshot: &[u8], + out_dir: Fd, + out_name: &[u8], + env: &mut bun_dotenv::Loader, +) -> crate::Result { + let exe = match read_executable_payload(exe_path) { + Ok(exe) => exe, + Err(failure) => return Ok(failure), + }; + let offsets = exe.offsets; + let full = &exe.file[exe.payload_start..exe.offsets_pos + size_of::() + TRAILER.len()]; + let previous_payload_len = if offsets.snapshot.length != 0 { + full.len() + } else { + 0 + }; + // Rebuild the snapshot-less form (body, then a cleared Offsets) and append to that. + let stripped: Vec; + let payload: &[u8] = if offsets.snapshot.length != 0 { + let body_end = offsets.snapshot.offset as usize; + if body_end > offsets.byte_count { + return Ok(CompileResult::fail_fmt(format_args!( + "corrupt snapshot offsets in {}", + bstr::BStr::new(exe_path) + ))); + } + let mut cleared = offsets; + cleared.snapshot = StringPointer::default(); + cleared.byte_count = body_end; + let mut out = Vec::with_capacity(body_end + size_of::() + TRAILER.len()); + out.extend_from_slice(&full[..body_end]); + // SAFETY: Offsets is repr(C) POD. + out.extend_from_slice(unsafe { + core::slice::from_raw_parts((&raw const cleared).cast::(), size_of::()) + }); + out.extend_from_slice(TRAILER); + stripped = out; + &stripped + } else { + full + }; + let Some(new_payload) = + append_startup_snapshot_to_serialized(payload, snapshot, previous_payload_len) + else { + return Ok(CompileResult::fail_fmt(format_args!( + "could not append the snapshot (payload trailer not recognized, or the snapshot or payload exceeds 4 GiB)" + ))); + }; + drop(exe); + rewrite_executable(exe_path, &new_payload, out_dir, out_name, env) +} + pub fn to_executable( target: &CompileTarget, output_files: &[OutputFile], @@ -1913,23 +2129,28 @@ pub fn to_executable( compile_exec_argv: &[u8], self_exe_path: Option<&[u8]>, flags: Flags, + prebuilt_payload: Option<&[u8]>, ) -> crate::Result { #[cfg(windows)] let _ = root_dir; - let bytes = match to_bytes( - module_prefix, - output_files, - output_format, - compile_exec_argv, - flags, - None, - ) { - Ok(b) => b, - Err(e) => { - return Ok(CompileResult::fail_fmt(format_args!( - "failed to generate module graph bytes: {}", - bstr::BStr::new(e.name()) - ))); + let bytes: Vec = if let Some(p) = prebuilt_payload { + p.to_vec() + } else { + match to_bytes( + module_prefix, + output_files, + output_format, + compile_exec_argv, + flags, + None, + ) { + Ok(b) => b, + Err(e) => { + return Ok(CompileResult::fail_fmt(format_args!( + "failed to generate module graph bytes: {}", + bstr::BStr::new(e.name()) + ))); + } } }; if bytes.is_empty() { diff --git a/test/js/bun/startup-snapshot/auto-fixture.js b/test/js/bun/startup-snapshot/auto-fixture.js new file mode 100644 index 000000000000..8c8e35f86d23 --- /dev/null +++ b/test/js/bun/startup-snapshot/auto-fixture.js @@ -0,0 +1,12 @@ +// A "zero-code" app: it does not call Bun.startupSnapshot.take(); `--snapshot` (auto) takes the snapshot once startup drains. +const table = Array.from({ length: 20000 }, (_, i) => ({ i, s: "row-" + i })); +const epoch = Bun.startupSnapshot.epoch(); +if (epoch > 0) { + console.log("[js] restored epoch", epoch, "rows", table.length); + process.exit(0); +} +process.on("restore", () => { + console.log("[js] restored epoch", Bun.startupSnapshot.epoch(), "rows", table.length); + process.exit(0); +}); +if (!Bun.startupSnapshot.isBuildingSnapshot()) console.log("[js] plain boot rows", table.length); diff --git a/test/js/bun/startup-snapshot/envgate-fixture.js b/test/js/bun/startup-snapshot/envgate-fixture.js new file mode 100644 index 000000000000..47f3195241bb --- /dev/null +++ b/test/js/bun/startup-snapshot/envgate-fixture.js @@ -0,0 +1,7 @@ +const epoch = Bun.startupSnapshot.epoch(); +void process.env.APP_MODE; // read before the freeze, but gated: the build report must not nag about it +void process.env.UNGATED_VAR; // read before the freeze and not gated: the report names it +if (epoch > 0) { console.log("[js] restored APP_MODE=" + (process.env.APP_MODE ?? "")); process.exit(0); } +process.on("restore", () => { console.log("[js] restored APP_MODE=" + (process.env.APP_MODE ?? "")); process.exit(0); }); +if (Bun.startupSnapshot.isBuildingSnapshot()) setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel", envGate: ["APP_MODE", "APP_UNSET_TOO"] }), 30); +else { console.log("[js] plain boot APP_MODE=" + (process.env.APP_MODE ?? "")); } diff --git a/test/js/bun/startup-snapshot/io-fixture.js b/test/js/bun/startup-snapshot/io-fixture.js new file mode 100644 index 000000000000..1438bb56df57 --- /dev/null +++ b/test/js/bun/startup-snapshot/io-fixture.js @@ -0,0 +1,4 @@ +// Reads a file while starting up: refused under the default (strict) policy, allowed and reported under BUN_STARTUP_SNAPSHOT_IO=local. +const bytes = require("fs").readFileSync(process.execPath).length; +if (Bun.startupSnapshot.epoch() > 0) { console.log("[js] restored, exe bytes", bytes); process.exit(0); } +process.on("restore", () => { console.log("[js] restored, exe bytes", bytes); process.exit(0); }); diff --git a/test/js/bun/startup-snapshot/main-fixture.js b/test/js/bun/startup-snapshot/main-fixture.js new file mode 100644 index 000000000000..dbcb3f7b0852 --- /dev/null +++ b/test/js/bun/startup-snapshot/main-fixture.js @@ -0,0 +1,8 @@ +// The command-line-tool shape: everything imported at the top level ends up in the snapshot; the program runs after restore. +const table = Array.from({ length: 5000 }, (_, i) => "entry-" + i); +let mainCalls = 0; +Bun.startupSnapshot.main(() => { + mainCalls++; + console.log(`[js] main epoch=${Bun.startupSnapshot.epoch()} args=${JSON.stringify(process.argv.slice(2))} cwd=${require("path").basename(process.cwd())} table=${table.length} calls=${mainCalls}`); +}); +if (Bun.startupSnapshot.isBuildingSnapshot()) console.log("[js] loading only; main deferred"); diff --git a/test/js/bun/startup-snapshot/signal-fixture.js b/test/js/bun/startup-snapshot/signal-fixture.js new file mode 100644 index 000000000000..41c9a99d1c90 --- /dev/null +++ b/test/js/bun/startup-snapshot/signal-fixture.js @@ -0,0 +1,6 @@ +// A signal listener registered while modules load, i.e. before the snapshot; the kernel-side handler has to exist in every launch. +process.on("SIGUSR1", () => { console.log(`[js] SIGUSR1 handled in epoch ${Bun.startupSnapshot.epoch()}`); process.exit(0); }); +Bun.startupSnapshot.main(() => { + process.kill(process.pid, "SIGUSR1"); + setTimeout(() => { console.log("[js] handler never ran"); process.exit(1); }, 5000); +}); diff --git a/test/js/bun/startup-snapshot/startup-snapshot-build.test.ts b/test/js/bun/startup-snapshot/startup-snapshot-build.test.ts new file mode 100644 index 000000000000..1236e172da68 --- /dev/null +++ b/test/js/bun/startup-snapshot/startup-snapshot-build.test.ts @@ -0,0 +1,488 @@ +import { expect } from "bun:test"; +import { existsSync, readdirSync } from "fs"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { join } from "path"; +import { buildEnv, restoreEnv, snapshotTest, withSnapshots } from "./startup-snapshot-harness"; + +const arch = process.arch === "arm64" ? "aarch64" : "x86_64"; +const setarch = isLinux ? Bun.which("setarch") : null; +const canDisableAslr = + !!setarch && Bun.spawnSync({ cmd: [setarch, arch, "-R", "true"], stdout: "ignore", stderr: "ignore" }).exitCode === 0; +const overlapTest = withSnapshots(canDisableAslr); +// Statics that cache a process-specific address get baked into the snapshot; WTF's stack-bounds code on Linux caches the +// original `environ` (a stack address) and clamps the main thread's stack origin to it whenever the bounds contain it. +// Restored, that is the build process's stack address, and a launch whose stack ASLR happened to place over the same +// range died in JSC's stack sanitizer. Forced deterministically: no ASLR for both processes, and a build environment +// large enough that the builder's environ sits well below where the restored process's frames end up. +overlapTest( + "restore: the main thread's stack bounds are this process's even when its stack overlaps where the builder's was", + async () => { + using dir = tempDir("bun-snapshot-stack-overlap", {}); + const exe = join(String(dir), "app"); + const padding: Record = {}; + for (let i = 0; i < 14; i++) padding[`SNAPSHOT_TEST_PAD_${i}`] = Buffer.alloc(96 * 1024, "x").toString(); // 14 × 96 KB, each under Linux's 128 KB per-string limit + const build = Bun.spawnSync({ + cmd: [ + setarch!, + arch, + "-R", + bunExe(), + "build", + "--compile", + "--snapshot=manual", + join(import.meta.dir, "smoke-fixture.js"), + "--outfile", + exe, + ], + env: { ...buildEnv, ...padding }, + stderr: "pipe", + stdout: "pipe", + }); + expect(build.stderr.toString() + build.stdout.toString()).toMatch(/embedded a .* snapshot/); + await using proc = Bun.spawn({ + cmd: [setarch!, arch, "-R", exe], + env: restoreEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("[js] tick 3"); + expect(exitCode).toBe(0); + }, +); + +snapshotTest("a stale sidecar cannot stand in for a snapshot the app failed to take", async () => { + using dir = tempDir("bun-snapshot-stale-sidecar", { "app.js": `process.exit(3);` }); + const exe = join(String(dir), "app"); + await Bun.write(exe + ".snapshot", "left over from an earlier build"); + const build = Bun.spawnSync({ + cmd: [bunExe(), "build", "--compile", "--snapshot", "app.js", "--outfile", exe], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + expect(build.stderr.toString()).toContain("exited with status 3"); + expect(build.exitCode).not.toBe(0); + expect(existsSync(exe + ".snapshot")).toBe(false); +}); + +snapshotTest( + "bun build --compile --snapshot embeds the snapshot; the single file restores from itself with no env", + async () => { + using dir = tempDir("bun-snapshot-compile", {}); + using out = tempDir("bun-snapshot-compile-out", {}); // the fixture's own output; the launch dir below must stay untouched + const exe = join(String(dir), "heavy"); + const build = Bun.spawnSync({ + cmd: [ + bunExe(), + "build", + "--compile", + "--bytecode", + "--format=esm", + "--snapshot=manual", + join(import.meta.dir, "heavy-fixture.js"), + "--outfile", + exe, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const buildOut = build.stderr.toString() + build.stdout.toString(); + expect(buildOut).toContain("[snapshot] wrote"); + expect(buildOut).toContain("MB snapshot into the executable"); + // Nothing beside the executable: the snapshot is in its __BUN/.bun section, and a launch maps the executable itself. + expect(readdirSync(String(dir)).sort()).toEqual(["heavy"]); + const rawMB = Number(/\[snapshot\] wrote .*?: \d+ regions, ([\d.]+)MB/.exec(buildOut)?.[1]); + expect(rawMB).toBeGreaterThan(1); + expect(Bun.file(exe).size).toBeGreaterThan(Bun.file(bunExe()).size + rawMB * 1048576 * 0.9); // embedded as is + for (const run of [1, 2]) { + await using proc = Bun.spawn({ + cmd: [exe], + env: { + HOME: String(dir), + PATH: bunEnv.PATH!, + BUN_STARTUP_SNAPSHOT_VERBOSE: "1", + HEAVY_OUT: join(String(out), "heavy.out"), + }, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A compiled executable that is not taking a snapshot keeps its own early heap above snapshot space, so whatever libc + // allocated before the restore is not overlaid by it. + const probeHex = /pre-restore heap probe=0x([0-9a-f]+)/.exec(stderr)?.[1]; + expect(probeHex).toBeDefined(); + expect(BigInt("0x" + probeHex!)).toBeGreaterThanOrEqual(0x21000000000n); + expect(stderr).toContain("[snapshot] restored"); + // What gets copied back in (as opposed to mapped) is the executable's own data segment, a few MB; the compiled + // payload (this build ships bytecode, so tens of MB) must never be part of it — that showed up as every launch + // touching all of it. + const copied = Number(/([\d.]+)MB __DATA copied/.exec(stderr)?.[1]); + expect(copied).toBeGreaterThan(0); + expect(copied).toBeLessThan(8); + expect(stdout).toContain("epoch 1"); + expect(stdout).toContain("fetch -> hello from restored server"); + expect(exitCode).toBe(0); + } + expect(readdirSync(String(dir)).sort()).toEqual(["heavy"]); // launches wrote nothing anywhere (HOME is this dir) + // Opt out boots normally. + const plain = Bun.spawnSync({ + cmd: [exe], + env: { + HOME: bunEnv.HOME!, + PATH: bunEnv.PATH!, + BUN_STARTUP_SNAPSHOT: "0", + HEAVY_OUT: join(String(out), "heavy.out"), + }, + stderr: "pipe", + stdout: "pipe", + }); + expect(plain.stdout.toString()).toContain("epoch 0"); + expect(plain.exitCode).toBe(0); + // Debugging: an explicit snapshot file still wins (BUN_STARTUP_SNAPSHOT_KEEP_SIDECAR keeps .snapshot next to it at build time). + const dbg = join(String(dir), "dbg"); + const b2 = Bun.spawnSync({ + cmd: [ + bunExe(), + "build", + "--compile", + "--bytecode", + "--format=esm", + "--snapshot", + join(import.meta.dir, "heavy-fixture.js"), + "--outfile", + dbg, + ], + env: { ...bunEnv, BUN_STARTUP_SNAPSHOT_KEEP_SIDECAR: "1" }, + stderr: "pipe", + stdout: "pipe", + }); + expect(b2.exitCode).toBe(0); + expect(Bun.file(dbg + ".snapshot").size).toBeGreaterThan(1024 * 1024); + const viaFile = Bun.spawnSync({ + cmd: [dbg], + env: { + HOME: bunEnv.HOME!, + PATH: bunEnv.PATH!, + BUN_STARTUP_SNAPSHOT_IN: dbg + ".snapshot", + HEAVY_OUT: join(String(dir), "heavy.out"), + }, + stderr: "pipe", + stdout: "pipe", + }); + expect(viaFile.stdout.toString()).toContain("epoch 1"); + expect(viaFile.exitCode).toBe(0); + }, +); + +snapshotTest( + "envGate: the snapshot is only restored when the gated environment variables agree with the build", + async () => { + using dir = tempDir("bun-snapshot-envgate", {}); + const img = join(String(dir), "g.snapshot"); + const fixture = join(import.meta.dir, "envgate-fixture.js"); + { + const b = Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, UNGATED_VAR: "1" }, + stderr: "pipe", + stdout: "pipe", + }); + const err = b.stderr.toString(); + expect(err).toContain("[snapshot] wrote"); + // The report lists what was read by name before the freeze, minus the gated names. + expect(err).toMatch(/values read from process.env before the freeze[^\n]*\n(?:[^\n]*\n)? [^\n]*\bUNGATED_VAR\b/); + expect(err).not.toMatch(/\n [^\n]*\bAPP_MODE\b/); + } + const run = (extra: Record) => + Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, ...extra }, + stderr: "pipe", + stdout: "pipe", + }); + expect(run({}).stdout.toString()).toContain("[js] restored APP_MODE="); // same environment as the build: restored + const gated = run({ APP_MODE: "special" }); + expect(gated.stdout.toString()).toContain("[js] plain boot APP_MODE=special"); // a gated variable differs: normal boot + expect(gated.stderr.toString()).not.toContain("[snapshot] restored"); + const other = run({ SOME_OTHER_VAR: "1" }); + expect(other.stdout.toString()).toContain("[js] restored"); // ungated variables don't matter + }, +); + +const runEnv = () => ({ HOME: bunEnv.HOME!, PATH: bunEnv.PATH! }); +function build(args: string[]) { + const r = Bun.spawnSync({ cmd: [bunExe(), "build", ...args], env: bunEnv, stderr: "pipe", stdout: "pipe" }); + return { out: r.stderr.toString() + r.stdout.toString(), code: r.exitCode }; +} +function runExe(exe: string, extraEnv: Record = {}) { + const r = Bun.spawnSync({ cmd: [exe], env: { ...runEnv(), ...extraEnv }, stderr: "pipe", stdout: "pipe" }); + return { stdout: r.stdout.toString(), stderr: r.stderr.toString(), code: r.exitCode }; +} + +snapshotTest( + "--snapshot is rejected, not silently dropped, when --compile --target=browser produces a standalone HTML file", + () => { + using dir = tempDir("bun-snapshot-html", { "page.html": "x" }); + const r = build([ + "--compile", + "--target=browser", + "--snapshot", + join(String(dir), "page.html"), + "--outfile", + join(String(dir), "out.html"), + ]); + expect(r.out).toContain("cannot use --compile --target browser with --snapshot"); + expect(r.code).toBe(1); // used to exit 0 with the flag ignored + }, +); + +snapshotTest("--snapshot (auto): an app with no snapshot call gets its snapshot once startup drains", () => { + using dir = tempDir("bun-snapshot-auto", {}); + const exe = join(String(dir), "app"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "auto-fixture.js"), "--outfile", exe]); + expect(b.out).toContain("[snapshot] embedded"); + expect(b.code).toBe(0); + const r = runExe(exe); + expect(r.stdout).toContain("[js] restored epoch 1 rows 20000"); + expect(r.code).toBe(0); +}); + +snapshotTest( + "the snapshot step runs on its own against an executable built earlier, in place, and can be re-run", + () => { + using dir = tempDir("bun-snapshot-split", {}); + const exe = join(String(dir), "app"); + const compiled = build(["--compile", join(import.meta.dir, "auto-fixture.js"), "--outfile", exe]); + expect(compiled.code).toBe(0); + expect(runExe(exe).stdout).toContain("[js] plain boot"); // no snapshot yet + const sizeBefore = Bun.file(exe).size; + const first = build(["--snapshot", "--outfile", exe]); + expect(first.out).toContain("[snapshot] embedded"); + expect(first.code).toBe(0); + expect(runExe(exe).stdout).toContain("[js] restored epoch 1"); + const sizeWithSnapshot = Bun.file(exe).size; + expect(sizeWithSnapshot).toBeGreaterThan(sizeBefore); + const again = build(["--snapshot", "--outfile", exe]); + expect(again.out).toContain("[snapshot] embedded"); + expect(again.code).toBe(0); + expect(runExe(exe).stdout).toContain("[js] restored epoch 1"); + // Replaced, not stacked: the second snapshot takes the place of the first (allow a page of alignment slack either way). + expect(Bun.file(exe).size - sizeWithSnapshot).toBeLessThan((sizeWithSnapshot - sizeBefore) / 2); // rewritten in place (or the block replaced): the file never accumulates superseded blocks + // Misuse is explained. + expect(build(["--snapshot", join(import.meta.dir, "auto-fixture.js")]).out).toContain("--snapshot needs --compile"); + expect(build(["--snapshot", "--outfile", join(String(dir), "missing")]).out).toContain("could not read"); + }, +); + +snapshotTest( + "Bun.build({ snapshot }) is the flag's equivalent; it needs compile, and bad values are rejected up front", + async () => { + using dir = tempDir("bun-snapshot-jsapi", { + "page.html": "x", + "build.ts": [ + "const [exe, entry] = process.argv.slice(2);", + "const r = await Bun.build({ entrypoints: [entry], compile: { outfile: exe }, snapshot: true });", + "if (!r.success) { console.error(r.logs); process.exit(2); }", + "const bad = [", + " { snapshot: true },", + " { compile: { outfile: exe + '-bad' }, snapshot: 'yes' },", + " { target: 'bun-' + process.platform + '-' + (process.arch === 'arm64' ? 'arm64' : 'x64'), snapshot: 'yes' },", // the target shorthand enables compile: this one must reach snapshot validation + " { compile: { outfile: exe + '-bad' }, snapshot: { mode: 'sometimes' } },", + " { compile: { outfile: exe + '-bad' }, snapshot: { io: 'everything' } },", + " { entrypoints: [new URL('./page.html', import.meta.url).pathname], target: 'browser', compile: true, snapshot: true },", // standalone HTML is not a process + "];", + "for (const config of bad) {", + " try { await Bun.build({ entrypoints: [entry], ...config }); console.log('accepted', JSON.stringify(config)); }", + " catch (e) { console.log('rejected: ' + e.constructor.name + ': ' + e.message); }", + "}", + ].join("\n"), + }); + const exe = join(String(dir), "app"); + await using p = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "build.ts"), exe, join(import.meta.dir, "auto-fixture.js")], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr + stdout).toContain("[snapshot] embedded"); + expect(stdout.match(/rejected: TypeError: snapshot requires compile/g)).toHaveLength(1); // only the config with neither compile nor a bun target + expect(stdout.match(/rejected: TypeError: snapshot must be true or an object/g)).toHaveLength(2); // both with compile and with the target shorthand + expect(stdout).toContain('rejected: TypeError: snapshot.mode must be "auto" or "manual"'); + expect(stdout).toContain('rejected: TypeError: snapshot.io must be "strict", "local" or "network"'); + expect(stdout).toContain("rejected: TypeError: Cannot use snapshot with target 'browser'"); // the JS-API half of the standalone-HTML rule + expect(stdout).not.toContain("accepted"); + expect(code).toBe(0); + expect(runExe(exe).stdout).toContain("[js] restored epoch 1"); + }, +); + +snapshotTest( + "local I/O during the build is refused by default (the build fails, the executable is left as built) and reported when allowed", + () => { + using dir = tempDir("bun-snapshot-io", {}); + const strict = join(String(dir), "strict"); + const s = build(["--compile", "--snapshot", join(import.meta.dir, "io-fixture.js"), "--outfile", strict]); + expect(s.out).toContain("node:fs is not available while building a snapshot"); + expect(s.out).toMatch(/exited with status \d+ while its snapshot was being taken/); + expect(s.code).not.toBe(0); // --snapshot was asked for and there is none + expect(runExe(strict).stdout).toBe(""); // what is left is the plain executable, which boots normally (the fixture only prints when restored) + const local = join(String(dir), "local"); + const l = build([ + "--compile", + "--snapshot", + "--snapshot-io=local", + join(import.meta.dir, "io-fixture.js"), + "--outfile", + local, + ]); + expect(l.out).toContain("local I/O operations ran before the freeze"); + expect(l.out).toMatch(/node:fs x1 from:\n\s+at readFileSync/); // attributed to the call site + expect(l.out).toContain("[snapshot] embedded"); + expect(l.code).toBe(0); + expect(runExe(local).stdout).toMatch(/restored, exe bytes \d+/); + // The io option is meaningless without the snapshot step, and manual mode explains itself when the app never snapshots. + expect( + build([ + "--compile", + "--snapshot-io=local", + join(import.meta.dir, "auto-fixture.js"), + "--outfile", + join(String(dir), "x"), + ]).out, + ).toContain("only applies together with --snapshot"); + const m = build([ + "--compile", + "--snapshot=manual", + join(import.meta.dir, "auto-fixture.js"), + "--outfile", + join(String(dir), "manual"), + ]); + expect(m.out).toContain("with --snapshot=manual the app has to call Bun.startupSnapshot.take() before it exits"); + expect(m.code).toBe(1); + }, +); + +snapshotTest( + "Bun.startupSnapshot.main(): the program runs after restore with each launch's own argv and cwd; a snapshot taken with it accepts any invocation", + () => { + using dir = tempDir("bun-snapshot-main", { "a/.keep": "", "b/.keep": "" }); + const exe = join(String(dir), "tool"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "main-fixture.js"), "--outfile", exe]); + expect(b.out).toContain("[js] loading only; main deferred"); // the build run loaded the program without running it + expect(b.out).toContain("[snapshot] embedded"); + expect(b.code).toBe(0); + for (const [args, cwd] of [ + [["format", "x.ts"], "a"], + [[], "b"], + [["--version"], "a"], + ] as const) { + const r = Bun.spawnSync({ + cmd: [exe, ...args], + cwd: join(String(dir), cwd), + env: { ...runEnv(), BUN_STARTUP_SNAPSHOT_VERBOSE: "1" }, + stderr: "pipe", + stdout: "pipe", + }); + expect(r.stderr.toString()).toContain("[snapshot] restored"); // any argv resumes from the snapshot + expect(r.stdout.toString()).toContain( + `[js] main epoch=1 args=${JSON.stringify(args)} cwd=${cwd} table=5000 calls=1`, + ); + expect(r.exitCode).toBe(0); + } + // Without a snapshot, main() simply runs. + const plain = Bun.spawnSync({ + cmd: [bunExe(), join(import.meta.dir, "main-fixture.js"), "p", "q"], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + expect(plain.stdout.toString()).toContain('[js] main epoch=0 args=["p","q"]'); + }, +); + +snapshotTest( + "stdio set up before the snapshot follows each launch's descriptors: replaced when their kind changed, kept and resized when a terminal is a terminal again", + async () => { + using dir = tempDir("bun-snapshot-stdio", {}); + const exe = join(String(dir), "tool"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "stdio-fixture.js"), "--outfile", exe]); // built with piped stdio + expect(b.out).toContain("process.stdin/stdout/stderr were set up before the freeze"); + expect(b.out).toMatch(/process\.stdout from:\n\s+at \/\$bunfs\/root\/tool:\d+:\d+/); // compiled modules are named after the executable + expect(b.code).toBe(0); + // pipe at build time -> file at launch: replaced (the build's stream silently lost these bytes before). + const outFile = join(String(dir), "out.txt"); + const toFile = Bun.spawnSync({ cmd: [exe], env: runEnv(), stdout: Bun.file(outFile), stderr: "pipe" }); + expect(await Bun.file(outFile).text()).toBe( + "epoch=1 builtWithTTY=false nowTTY=false colors=false sameObject=false columns=undefined\n", + ); + expect(toFile.exitCode).toBe(0); + const onTerminal = async (cmd: string[], cols: number) => { + let seen = ""; + await using proc = Bun.spawn({ + cmd, + env: { ...runEnv(), TERM: "xterm-256color" }, + terminal: { + cols, + rows: 24, + data(_t, d) { + seen += new TextDecoder().decode(d); + }, + }, + }); + await proc.exited; + return seen; + }; + // pipe at build time -> terminal at launch: replaced by a terminal stream; Bun's own color decision follows the launch too. + expect(await onTerminal([exe], 80)).toContain( + "epoch=1 builtWithTTY=false nowTTY=true colors=true sameObject=false columns=80", + ); + // terminal at build time -> terminal at launch: the object the app captured is kept, with this terminal's size. + const exe2 = join(String(dir), "tool2"); + const built = await onTerminal( + [bunExe(), "build", "--compile", "--snapshot", join(import.meta.dir, "stdio-fixture.js"), "--outfile", exe2], + 60, + ); + expect(built).toMatch(/embedded a [\d.]+ MB snapshot/); // colored on a terminal, so not matched as one string + expect(await onTerminal([exe2], 100)).toContain( + "epoch=1 builtWithTTY=true nowTTY=true colors=true sameObject=true columns=100", + ); + }, +); + +snapshotTest("signal listeners registered before the snapshot are installed again in a restored launch", () => { + using dir = tempDir("bun-snapshot-signal", {}); + const exe = join(String(dir), "tool"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "signal-fixture.js"), "--outfile", exe]); + expect(b.out).toContain("[snapshot] embedded"); + expect(b.code).toBe(0); + const r = Bun.spawnSync({ cmd: [exe], env: runEnv(), stdout: "pipe", stderr: "pipe" }); + expect(r.stdout.toString()).toContain("[js] SIGUSR1 handled in epoch 1"); // unfixed: the process dies of the signal + expect(r.exitCode).toBe(0); +}); + +snapshotTest("WebAssembly instantiated before the snapshot works after restore, including traps", () => { + using dir = tempDir("bun-snapshot-wasm", {}); + const exe = join(String(dir), "tool"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "wasm-fixture.js"), "--outfile", exe]); + expect(b.out).toContain("[snapshot] embedded"); + expect(b.code).toBe(0); + const r = Bun.spawnSync({ cmd: [exe], env: runEnv(), stdout: "pipe", stderr: "pipe" }); + expect(r.stdout.toString()).toContain("[js] epoch=1 load(0)=7 out-of-bounds=RuntimeError"); // unfixed: the launch crashes on the trap + expect(r.exitCode).toBe(0); +}); + +snapshotTest("wasm tier-up compilations in flight when the snapshot is taken are quiesced first", () => { + using dir = tempDir("bun-snapshot-wasm-tierup", {}); + const exe = join(String(dir), "tool"); + const b = build(["--compile", "--snapshot", join(import.meta.dir, "wasm-tierup-fixture.js"), "--outfile", exe]); + expect(b.out).not.toContain("executable memory changed while the snapshot was being written"); + expect(b.out).toContain("[snapshot] embedded"); + expect(b.code).toBe(0); + const r = Bun.spawnSync({ cmd: [exe], env: runEnv(), stdout: "pipe", stderr: "pipe" }); + expect(r.stdout.toString()).toContain("[js] epoch=1 warmed=300000 sum=2000 bump=100001"); + expect(r.exitCode).toBe(0); +}); diff --git a/test/js/bun/startup-snapshot/stdio-fixture.js b/test/js/bun/startup-snapshot/stdio-fixture.js new file mode 100644 index 000000000000..6edacff0808b --- /dev/null +++ b/test/js/bun/startup-snapshot/stdio-fixture.js @@ -0,0 +1,10 @@ +// Like color-detection libraries and UI frameworks: process.stdout is set up (and a reference kept) while modules load, +// i.e. before the snapshot is taken. +const captured = process.stdout; +const builtWithTTY = captured.isTTY === true; +Bun.startupSnapshot.main(() => { + const now = process.stdout; + process.stdout.write( + `epoch=${Bun.startupSnapshot.epoch()} builtWithTTY=${builtWithTTY} nowTTY=${now.isTTY === true} colors=${Bun.enableANSIColors} sameObject=${captured === now} columns=${now.columns}\n`, + ); +}); diff --git a/test/js/bun/startup-snapshot/wasm-fixture.js b/test/js/bun/startup-snapshot/wasm-fixture.js new file mode 100644 index 000000000000..2c5a27bfc4f8 --- /dev/null +++ b/test/js/bun/startup-snapshot/wasm-fixture.js @@ -0,0 +1,16 @@ +// (func (export "load") (param i32) (result i32) local.get 0 i32.load) with one page of memory: an out-of-bounds load must +// trap (a RuntimeError), which relies on the signal/exception handlers the runtime installed — kernel state that a +// launch resumed from the snapshot has to install again. +const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, + 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x11, 0x02, 0x04, 0x6c, 0x6f, 0x61, 0x64, 0x00, 0x00, 0x06, 0x6d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x02, 0x00, 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x28, 0x02, 0x00, 0x0b, +]); +const { instance } = await WebAssembly.instantiate(bytes); +const { load, memory } = instance.exports; +new Uint32Array(memory.buffer)[0] = 7; // linear memory contents travel with the snapshot too +Bun.startupSnapshot.main(() => { + let trap = "none"; + try { load(0x7ffffff0); } catch (e) { trap = e.constructor.name; } + console.log(`[js] epoch=${Bun.startupSnapshot.epoch()} load(0)=${load(0)} out-of-bounds=${trap}`); +}); diff --git a/test/js/bun/startup-snapshot/wasm-tierup-fixture.js b/test/js/bun/startup-snapshot/wasm-tierup-fixture.js new file mode 100644 index 000000000000..268e962cd245 --- /dev/null +++ b/test/js/bun/startup-snapshot/wasm-tierup-fixture.js @@ -0,0 +1,20 @@ +// add(a, b) and bump() (a counter in linear memory), driven hard right before the snapshot so that tier-up compilations +// are in flight on the compiler threads when it is taken: they must be quiesced, or the snapshot ends up holding +// pointers to code that was installed after its pages were walked. +const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0b, 0x02, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x00, 0x01, + 0x7f, 0x03, 0x03, 0x02, 0x00, 0x01, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x17, 0x03, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, + 0x04, 0x62, 0x75, 0x6d, 0x70, 0x00, 0x01, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x1e, 0x02, 0x07, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, 0x14, 0x00, 0x41, 0x00, 0x41, 0x00, 0x28, 0x02, 0x00, 0x41, 0x01, 0x6a, 0x36, + 0x02, 0x00, 0x41, 0x00, 0x28, 0x02, 0x00, 0x0b, +]); +const { instance } = await WebAssembly.instantiate(bytes); +const { add, bump } = instance.exports; +let acc = 0; +for (let i = 0; i < 300000; i++) acc = add(acc, 1); +for (let i = 0; i < 100000; i++) bump(); +Bun.startupSnapshot.main(() => { + let sum = 0; + for (let i = 0; i < 1000; i++) sum = add(sum, 2); + console.log(`[js] epoch=${Bun.startupSnapshot.epoch()} warmed=${acc} sum=${sum} bump=${bump()}`); +});