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 6a47f7c54350..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 { @@ -4943,6 +4952,56 @@ declare module "bun" { function memoryFootprint(): number | undefined; } + /** + * Startup snapshots (experimental) — see `bun build --snapshot`. A snapshot of the started-up + * process is embedded in a compiled executable, and later launches resume from it instead of + * booting; `process.on("restore")` runs first thing in such a launch. + */ + namespace startupSnapshot { + /** + * The program itself, for tools that start, do a job and exit. Called immediately in a launch + * that has no snapshot; stored — not called — in the run that takes the snapshot, so the snapshot + * holds the loaded program; called after `"restore"` in a launch that resumes from the snapshot, + * with that launch's argv, cwd, environment and stdio. A snapshot taken with a `main()` registered + * is used for every invocation, whatever the arguments. + */ + function main(program: () => unknown): void; + + /** + * With `--snapshot=manual`, the point in startup at which the snapshot is taken. In the run + * `bun build` makes for that purpose this never returns: the process exits once the snapshot is + * written (or with a message naming what kept it busy). In every other process it returns at + * once, so it can be called unconditionally. With `--snapshot` (auto) the runtime picks the + * moment itself and a call only contributes the options. + */ + function take(options?: { + /** + * Timers still armed when the process goes quiet: `"keep"` lets them survive with their + * remaining time preserved across the restore; `"cancel"` drops them. By default armed + * timers keep a manual snapshot from being taken; auto mode keeps them. + */ + timers?: "keep" | "cancel"; + /** + * Environment variables the snapshotted startup depended on. A launch whose values for these + * differ from the build's boots normally instead of resuming from the snapshot. + */ + envGate?: string[]; + }): void; + + /** True only in the run `bun build --snapshot` makes to take the snapshot. */ + function isBuildingSnapshot(): boolean; + + /** 0 in a process that booted normally; otherwise how many times this process has been resumed from a snapshot. */ + function epoch(): number; + + /** + * In a process resumed from a snapshot: hand back to the shared snapshot any page this process + * wrote and then restored to its original contents. Cheap; call it once startup work has + * settled. A no-op elsewhere. + */ + function reclean(): void; + } + type DigestEncoding = "utf8" | "ucs2" | "utf16le" | "latin1" | "ascii" | "base64" | "base64url" | "hex"; /** diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 90c655d88185..7c4839684d1b 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -775,6 +775,7 @@ struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int f return (struct us_internal_async *) cb; } + // identical code as for timer, make it shared for "callback types" void us_internal_async_close(struct us_internal_async *a) { struct us_internal_callback_t *cb = (struct us_internal_callback_t *) a; @@ -1019,3 +1020,49 @@ int us_socket_get_error(struct us_socket_t *s) { } #endif + +#ifdef LIBUS_USE_EPOLL +/* Snapshot restore (Linux): fresh epoll fd; the wakeup async is an eventfd-backed poll — give it a new eventfd and re-add it. */ +void us_loop_reinit_for_snapshot(struct us_loop_t *loop) { + loop->fd = epoll_create1(EPOLL_CLOEXEC); + loop->num_ready_polls = 0; + loop->current_ready_poll = 0; + struct us_poll_t *p = (struct us_poll_t *) loop->data.wakeup_async; + if (p) { + int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (efd != -1) { + int events = us_poll_events(p); + us_poll_init(p, efd, us_internal_poll_type(p)); + us_poll_start(p, loop, events ? events : LIBUS_SOCKET_READABLE); + /* Same upgrade as us_internal_async_set(): the callback's leave_poll_ready (no drain) came with the snapshot and is + * only correct for an edge-triggered registration; level-triggered, the undrained eventfd would wake the loop forever. */ + struct epoll_event event; + event.events = EPOLLIN | EPOLLET; + event.data.ptr = p; + epoll_ctl(loop->fd, EPOLL_CTL_MOD, efd, &event); + } + } +} +#endif + +#ifdef LIBUS_USE_KQUEUE +#if defined(__APPLE__) +/* Experiment (snapshot restore): the loop struct came from another process; give it a fresh kqueue and + * re-create/re-register the wakeup mach port so us_wakeup_loop() works again. */ +void us_loop_reinit_for_snapshot(struct us_loop_t *loop) { + loop->fd = kqueue(); + loop->num_ready_polls = 0; + loop->current_ready_poll = 0; + struct us_internal_callback_t *cb = (struct us_internal_callback_t *) loop->data.wakeup_async; + if (cb) { + mach_port_t self = mach_task_self(); + if (mach_port_allocate(self, MACH_PORT_RIGHT_RECEIVE, &cb->port) == KERN_SUCCESS + && mach_port_insert_right(self, cb->port, cb->port, MACH_MSG_TYPE_MAKE_SEND) == KERN_SUCCESS) { + mach_port_limits_t limits = { .mpl_qlimit = 1 }; + mach_port_set_attributes(self, cb->port, MACH_PORT_LIMITS_INFO, (mach_port_info_t)&limits, MACH_PORT_LIMITS_INFO_COUNT); + us_internal_async_set((struct us_internal_async *) cb, (void (*)(struct us_internal_async *)) cb->cb); + } + } +} +#endif +#endif diff --git a/packages/bun-uws/src/Loop.h b/packages/bun-uws/src/Loop.h index b62bddf47469..e69eb2506051 100644 --- a/packages/bun-uws/src/Loop.h +++ b/packages/bun-uws/src/Loop.h @@ -108,6 +108,12 @@ struct Loop { } public: + /* snapshot restore: this thread's TLS is fresh but the loop object lives on (in the snapshot); make get() return it instead of creating a second loop. */ + static void adoptForCurrentThread(Loop *loop) { + getLazyLoop().loop = loop; + getLazyLoop().cleanMe = false; + } + /* Lazily initializes a per-thread loop and returns it. * Will automatically free all initialized loops at exit. */ static Loop *get(void *existingNativeLoop = nullptr) { diff --git a/patches/boringssl/fork-detect-startup-snapshot.patch b/patches/boringssl/fork-detect-startup-snapshot.patch new file mode 100644 index 000000000000..b63154adb7b6 --- /dev/null +++ b/patches/boringssl/fork-detect-startup-snapshot.patch @@ -0,0 +1,50 @@ +--- a/crypto/rand/fork_detect.cc ++++ b/crypto/rand/fork_detect.cc +@@ -171,6 +171,17 @@ + return current_generation; + } + ++// Bun: a process resumed from a startup snapshot inherits these statics from the process that built it, including a ++// page address that is not mapped here. Restore is single-threaded; installing this process's own page is enough, ++// since the once flag above already reads as done. ++extern "C" void CRYPTO_fork_detect_reinit_for_startup_snapshot(void) { ++ if (g_fork_detect_addr == nullptr) return; // never initialized (this process's once will), or WIPEONFORK was unavailable there (stays in the always-reseed fallback) ++ uint64_t generation_in_builder = g_fork_generation; ++ g_fork_detect_addr = nullptr; ++ init_fork_detect(); ++ g_fork_generation = generation_in_builder + 1; // a restore duplicates the address space like a fork: anything cached against the builder's value must reseed ++} ++ + void bssl::CRYPTO_fork_detect_force_madv_wipeonfork_for_testing(int on) { + g_force_madv_wipeonfork = 1; + g_force_madv_wipeonfork_enabled = on; +@@ -197,6 +208,14 @@ + g_atfork_fork_generation = 1; + } + ++// Bun: see the WIPEONFORK variant; here the build process's atfork registration does not exist in this process. ++extern "C" void CRYPTO_fork_detect_reinit_for_startup_snapshot(void) { ++ if (g_atfork_fork_generation == 0) return; // as above: nothing to redo if the build process never initialized it ++ uint64_t generation_in_builder = g_atfork_fork_generation; ++ init_pthread_fork_detection(); ++ g_atfork_fork_generation = generation_in_builder + 1; // as above ++} ++ + uint64_t bssl::CRYPTO_get_fork_generation() { + CRYPTO_once(&g_pthread_fork_detection_once, init_pthread_fork_detection); + +@@ -210,6 +229,7 @@ + // assume address space duplication is not a concern and adding entropy to + // every RAND_bytes call is not needed. + uint64_t bssl::CRYPTO_get_fork_generation() { return 0xc0ffee; } ++extern "C" void CRYPTO_fork_detect_reinit_for_startup_snapshot(void) {} + + #else + +@@ -218,5 +238,6 @@ + // space duplication could have occurred on any call entropy must be added to + // every RAND_bytes call. + uint64_t bssl::CRYPTO_get_fork_generation() { return 0; } ++extern "C" void CRYPTO_fork_detect_reinit_for_startup_snapshot(void) {} + + #endif diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 395739e710bc..14a90a692db5 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -39,7 +39,9 @@ export const boringssl: Dependency = { // Upstream mem.cc gates OPENSSL_memory_* weak-symbol overrides on __ELF__; // on Mach-O/COFF the hooks compile to static nullptr and OPENSSL_malloc goes // straight to libc. Declare them as plain externs so lib.rs binds everywhere. - patches: ["patches/boringssl/require-memory-hooks.patch"], + // fork-detect: lets a process resumed from a startup snapshot re-run fork detection's per-process setup + // (its statics arrive holding the build process's WIPEONFORK page / atfork registration). + patches: ["patches/boringssl/require-memory-hooks.patch", "patches/boringssl/fork-detect-startup-snapshot.patch"], build: cfg => { // win-x64 uses NASM-syntax .asm; everything else (including win-aarch64) diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 8e03973d6a0e..da88a6694a50 100644 --- a/scripts/build/deps/mimalloc.ts +++ b/scripts/build/deps/mimalloc.ts @@ -12,7 +12,7 @@ import type { Dependency, DirectBuild } from "../source.ts"; -const MIMALLOC_COMMIT = "1803341d6241d8fa4b3f65fa68cb13a32ad92f04"; +const MIMALLOC_COMMIT = "7aca49e5b5b49ce2e44490a604d93a4be7a39759"; // oven-sh/mimalloc#13 (snapshot support); swap for the merge sha before landing export const mimalloc: Dependency = { name: "mimalloc", @@ -27,14 +27,16 @@ export const mimalloc: Dependency = { build: cfg => { // ─── Override behavior (global malloc replacement) ─── // ASAN: OFF — ASAN interceptors must see the real malloc. - // macOS: OFF — overriding via zone/interpose breaks NAPI addons and - // system frameworks (SecureTransport etc.). + // macOS: OFF by default — overriding via zone/interpose breaks NAPI addons + // and system frameworks (SecureTransport etc.); BUN_MIMALLOC_OVERRIDE_DARWIN=1 + // opts in (what startup snapshots need there). // Linux: ON — the main win. All malloc/free routes through mimalloc, // including WebKit's bmalloc when it falls back to system malloc. // Windows: OFF — Bun links the static CRT and calls mi_* directly; // alloc-override.c emits _expand/_msize/free which duplicate // against libucrt(d) at link time. - const override = cfg.linux && !cfg.asan; + const override = !cfg.asan && (cfg.linux || (cfg.darwin && process.env.BUN_MIMALLOC_OVERRIDE_DARWIN === "1")); + const osxZone = cfg.darwin && !cfg.asan && process.env.BUN_MIMALLOC_OVERRIDE_DARWIN === "1"; const defines: Record = { // The .a path; gates symbol visibility in mimalloc/internal.h. @@ -67,6 +69,13 @@ export const mimalloc: Dependency = { if (cfg.abi === "musl") defines.MI_LIBC_MUSL = 1; if (override) defines.MI_MALLOC_OVERRIDE = true; + if (osxZone) defines.MI_OSX_ZONE = 1; + + // Snapshots (src/jsc/bindings/StartupSnapshot.cpp): executables carrying a snapshot get deterministic address hints from + // their first allocation; a process building one (BUN_STARTUP_SNAPSHOT_OUT) keeps its heap at the base that becomes the snapshot. + // Only where snapshots exist (Snapshot.h): the hook runs inside mimalloc's own initialization, before anything else. + const snapshots = cfg.darwin || cfg.linux; + if (snapshots) defines.MI_STARTUP_SNAPSHOT_BUILD_ENV = "BUN_STARTUP_SNAPSHOT_OUT"; // quoted into a C string literal by the builder if (cfg.debug) { // Heavy debug checks: guard bytes, freed-memory poisoning, double-free @@ -93,6 +102,9 @@ export const mimalloc: Dependency = { // Bare token (mi_stringify() pastes it into the banner string), so // it can't go through DirectBuild.defines which would quote it. `-DMI_CMAKE_BUILD_TYPE=${cfg.buildType.toLowerCase()}`, + // Bare token as well: the name of the function (defined in c-bindings.cpp) mimalloc calls to learn whether this + // executable carries a snapshot; see the MI_STARTUP_SNAPSHOT_* note above. + ...(snapshots ? ["-DMI_STARTUP_SNAPSHOT_HOST_FN=bun_startup_snapshot_placement_wanted"] : []), ]; // TLS model: initial-exec for the static link into bun's executable diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 37386d50099e..36596b283a0c 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "447082ab6897278727b44e1ba3c326ae6e1504c3"; +export const WEBKIT_VERSION = "autobuild-preview-pr-397-4c0ca85e"; // oven-sh/WebKit#397 (snapshot support, on current main) — swap for the merge sha before landing /** * WebKit (JavaScriptCore) — the JS engine. @@ -331,6 +331,7 @@ export const webkit: Dependency = { CMAKE_EXPORT_COMPILE_COMMANDS: "ON", USE_BUN_JSC_ADDITIONS: "ON", USE_BUN_EVENT_LOOP: "ON", + ...(cfg.windows || cfg.asan ? {} : { USE_MIMALLOC: "ON", USE_EXTERNAL_MIMALLOC: "ON" }), // as every other mimalloc routing: not under ASAN ENABLE_BUN_SKIP_FAILING_ASSERTIONS: "ON", ALLOW_LINE_AND_COLUMN_NUMBER_IN_BUILTINS: "ON", ENABLE_REMOTE_INSPECTOR: "ON", diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 59e199b6e579..d87a26d6fe82 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -728,6 +728,11 @@ export const defines: Flag[] = [ flag: "USE_BUN_MIMALLOC=1", desc: "Use mimalloc as default allocator", }, + { + flag: "BUN_MIMALLOC_ZONE_OVERRIDE=1", + when: c => c.darwin && !c.asan && process.env.BUN_MIMALLOC_OVERRIDE_DARWIN === "1", // keep in step with `osxZone` in deps/mimalloc.ts + desc: "mimalloc is registered as the process's malloc zone (what startup snapshots need on macOS)", + }, // ─── Config-dependent ─── { diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index ed3994d474b1..eaac44dfb70a 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -990,6 +990,8 @@ unsafe extern "C" { /// In the event that sufficient random data can not be obtained, `abort` /// is called. See `rand_bytes` for the safe wrapper. pub(crate) fn RAND_bytes(buf: *mut u8, len: usize) -> c_int; + /// Bun addition (patches/boringssl/fork-detect-startup-snapshot.patch): redo fork detection's per-process setup. + pub(crate) fn CRYPTO_fork_detect_reinit_for_startup_snapshot(); // ── ERR ────────────────────────────────────────────────────────────── // Thread-local error queue — no pointer args, no preconditions. diff --git a/src/boringssl_sys/lib.rs b/src/boringssl_sys/lib.rs index d898c92b7375..3adbb7e916cc 100644 --- a/src/boringssl_sys/lib.rs +++ b/src/boringssl_sys/lib.rs @@ -3,6 +3,15 @@ pub mod boringssl; pub use boringssl::*; +/// After a startup-snapshot restore: fork detection's statics describe the build process (patches/boringssl/fork-detect-startup-snapshot.patch). +/// # Safety +/// No other thread may be using BoringSSL: it rewrites the library's fork-detection statics. A snapshot restore, before +/// any other thread of the new process exists, is the intended caller. +pub unsafe fn reinit_fork_detection_after_snapshot_restore() { + // SAFETY: the caller upholds the single-threaded requirement above; the hook touches only BoringSSL's own statics. + unsafe { boringssl::CRYPTO_fork_detect_reinit_for_startup_snapshot() } +} + /// Fill `buf` with cryptographically-secure random bytes via BoringSSL `RAND_bytes`. /// /// BoringSSL's `RAND_bytes` is a thread-local AES-CTR DRBG seeded once from the diff --git a/src/bun_alloc/lib.rs b/src/bun_alloc/lib.rs index d99683419c75..9e642ecb2073 100644 --- a/src/bun_alloc/lib.rs +++ b/src/bun_alloc/lib.rs @@ -1432,7 +1432,7 @@ macro_rules! bss_singleton { fn slow() -> *mut $ty { let p = $crate::bss_heap_init::<$ty>(<$ty>::init_at).as_ptr(); // Race: two threads may both reach here. The mmap'd region is - // process-lifetime and never freed, so the loser is leaked + // process-lifetime and never freed, so the loser is leaked (its arena bytes too: first touch is single-threaded, so unlike the arena mapping this need not be claim-first) // (≤ one per declare site, which in practice is single-threaded // — `FileSystem::init` runs once on the main thread). The CAS // is the publication barrier. @@ -1533,26 +1533,32 @@ fn bss_arena_bump(size: usize, align: usize) -> *mut u8 { static CURSOR: AtomicUsize = AtomicUsize::new(0); // Resolve the arena base. Fast path is one Acquire load; the cold path - // maps the 4 MiB region once and publishes via CAS. A losing racer's - // mapping is leaked (≤ one per process; `MAP_NORESERVE` so it costs no - // committed memory) — same race policy as `bss_singleton!`. + // maps the 4 MiB region exactly once: a racer claims the right to map before mapping, and the others wait for the + // result. (Map-then-race would let a loser consume a placement hint too, and the arena's address has to be the same in + // every process that may build or restore a snapshot.) let mut base = BASE.load(Ordering::Acquire); if base.is_null() { + static CLAIMED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); #[cold] #[inline(never)] - fn map_arena() -> *mut u8 { - bss_mmap_noreserve(BSS_ARENA_SIZE) + fn map_arena_once() -> *mut u8 { + if CLAIMED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let fresh = bss_mmap_noreserve(BSS_ARENA_SIZE); + BASE.store(fresh, Ordering::Release); + return fresh; + } + loop { + let b = BASE.load(Ordering::Acquire); + if !b.is_null() { + return b; + } + core::hint::spin_loop(); + } } - let fresh = map_arena(); - base = match BASE.compare_exchange( - core::ptr::null_mut(), - fresh, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => fresh, - Err(winner) => winner, // leak `fresh` (untouched MAP_NORESERVE) - }; + base = map_arena_once(); } // Bump the cursor: round up to `align`, reserve `size`. CAS loop because @@ -1579,6 +1585,37 @@ fn bss_arena_bump(size: usize, align: usize) -> *mut u8 { } } +/// Where a snapshot may be built or mapped (the targets deps/mimalloc.ts builds the hint machinery for), this reservation +/// has to land at the same address in every process; the allocator decides that once and hands out the same kind of bump +/// hint it uses for its own reservations. Null = no preference. +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] +fn snapshot_reserve_hint(len: usize) -> *mut libc::c_void { + // Bottom of the address window StartupSnapshot.cpp captures as ours (0x1f0'0000'0000..); mimalloc's own hinted arenas start above it. + const SNAPSHOT_RESERVE_BASE: usize = 0x1f0_0000_0000; + const SNAPSHOT_RESERVE_ALIGN: usize = 4 << 20; + static SNAPSHOT_HINT: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + let mut hint: *mut libc::c_void = core::ptr::null_mut(); + if mimalloc::mi_startup_snapshot_hints_enabled() { + let _ = SNAPSHOT_HINT.compare_exchange( + 0, + SNAPSHOT_RESERVE_BASE, + core::sync::atomic::Ordering::AcqRel, + core::sync::atomic::Ordering::Acquire, + ); + let aligned = (len + SNAPSHOT_RESERVE_ALIGN - 1) & !(SNAPSHOT_RESERVE_ALIGN - 1); + hint = SNAPSHOT_HINT.fetch_add(aligned, core::sync::atomic::Ordering::AcqRel) + as *mut libc::c_void; + } + hint +} +#[cfg(all( + unix, + not(any(target_os = "macos", target_os = "linux", target_os = "android")) +))] +fn snapshot_reserve_hint(_len: usize) -> *mut libc::c_void { + core::ptr::null_mut() +} + /// One `mmap(MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE)` of `len` RW bytes. /// Aborts on `MAP_FAILED`. Returned pointer is page-aligned and the region /// reads as all-zeros until written. @@ -1595,11 +1632,12 @@ fn bss_mmap_noreserve(len: usize) -> *mut u8 { const MAP_FLAGS: libc::c_int = libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_NORESERVE; #[cfg(not(any(target_os = "linux", target_os = "android")))] const MAP_FLAGS: libc::c_int = libc::MAP_PRIVATE | libc::MAP_ANONYMOUS; + let hint = snapshot_reserve_hint(len); // SAFETY: anonymous private mapping — fd/offset ignored, `len` is non-zero - // (callers pass `size_of` of a non-ZST); failure handled below. + // (callers pass `size_of` of a non-ZST); the hint is advisory; failure handled below. let p = unsafe { libc::mmap( - core::ptr::null_mut(), + hint, len, libc::PROT_READ | libc::PROT_WRITE, MAP_FLAGS, diff --git a/src/bun_bin/lib.rs b/src/bun_bin/lib.rs index ba124904593d..d26134723ab6 100644 --- a/src/bun_bin/lib.rs +++ b/src/bun_bin/lib.rs @@ -169,6 +169,12 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> libc::signal(libc::SIGPIPE, libc::SIG_IGN); libc::signal(libc::SIGXFSZ, libc::SIG_IGN); } + // Every platform: where snapshots are unsupported this is the stub that refuses the environment knobs out loud. + unsafe extern "C" { + fn Bun__startupSnapshotInit(); + } + // SAFETY: no arguments; called once, on the main thread, before any other thread exists. + unsafe { Bun__startupSnapshotInit() }; // Windows-only startup. Must run BEFORE the first libuv // call (uv allocator) and before anything reads `Bun.env`/`process.env` @@ -199,6 +205,15 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> // wires stdout/stderr `Source`s. output::stdio::init(); let _flush = output::flush_guard(); + // Snapshot: with stdio and Output ready, a process that has a snapshot to map diverges here and never returns. + #[cfg(unix)] + // SAFETY: single-threaded startup; the callee takes no arguments and either returns or continues the snapshotd process. + unsafe { + unsafe extern "C" { + fn Bun__startupSnapshotMaybeRestore(); + } + Bun__startupSnapshotMaybeRestore(); + } // 5. Per-thread stack-limit cache for the JS recursion guard. StackCheck::configure_thread(); diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 9ca6d375d233..93c105b504a8 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -34,6 +34,17 @@ use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU64, AtomicUsize, Ordering}; // MOVE_DOWN: bun_core::ZStr → bun_core (move-in pass). use crate::ZStr; +/// Publishes the restore epoch a cached value was (re)loaded in, after the value itself, whatever path the loader took. +struct PublishEpoch<'a>(&'a core::sync::atomic::AtomicU32); +impl Drop for PublishEpoch<'_> { + fn drop(&mut self) { + self.0.store( + crate::startup_snapshot::epoch(), + core::sync::atomic::Ordering::Release, + ); + } +} + // ────────────────────────────────────────────────────────────────────────────── // Declarations // ────────────────────────────────────────────────────────────────────────────── @@ -87,6 +98,14 @@ new!(pub BUN_FEATURE_FLAG_DUMP_CODE: string, "BUN_FEATURE_FLAG_DUMP_CODE", {}); new!(pub BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS: unsigned, "BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS", {}); new!(pub BUN_GC_TIMER_DISABLE: boolean, "BUN_GC_TIMER_DISABLE", {}); new!(pub BUN_GC_TIMER_INTERVAL: unsigned, "BUN_GC_TIMER_INTERVAL", {}); +new!(pub BUN_STARTUP_SNAPSHOT_VERBOSE: boolean, "BUN_STARTUP_SNAPSHOT_VERBOSE", {}); +// Set for the process `bun build --snapshot` runs to take the snapshot: what it may touch on this machine ("strict" unless "local"), +// and whether the runtime takes the snapshot itself once startup drains (auto) or waits for Bun.startupSnapshot.take() (manual). +new!(pub BUN_STARTUP_SNAPSHOT_OUT: string, "BUN_STARTUP_SNAPSHOT_OUT", {}); +new!(pub BUN_STARTUP_SNAPSHOT_IO: string, "BUN_STARTUP_SNAPSHOT_IO", {}); +new!(pub BUN_STARTUP_SNAPSHOT_AUTO: boolean, "BUN_STARTUP_SNAPSHOT_AUTO", {}); +new!(pub BUN_STARTUP_SNAPSHOT_KEEP_SIDECAR: boolean, "BUN_STARTUP_SNAPSHOT_KEEP_SIDECAR", {}); +new!(pub BUN_STARTUP_SNAPSHOT_QUIET_TIMEOUT: unsigned, "BUN_STARTUP_SNAPSHOT_QUIET_TIMEOUT", {}); // TODO(markovejnovic): It's unclear why the default here is 100_000, but this was legacy behavior // so we'll keep it for now. new!(pub BUN_INOTIFY_COALESCE_INTERVAL: unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", { default: 100_000 }); @@ -332,6 +351,7 @@ pub(crate) mod kind { pub(crate) struct Cache { ptr_value: AtomicPtr, len_value: AtomicUsize, + epoch: core::sync::atomic::AtomicU32, // snapshot restore epoch the value was loaded in; stale => reload } type PointerType = *mut u8; // AtomicPtr requires *mut @@ -347,10 +367,14 @@ pub(crate) mod kind { Self { ptr_value: AtomicPtr::new(NOT_LOADED_PTR), len_value: AtomicUsize::new(NOT_LOADED_LEN), + epoch: core::sync::atomic::AtomicU32::new(0), } } pub(crate) fn get_cached(&self) -> Output { + if self.epoch.load(Ordering::Acquire) != crate::startup_snapshot::epoch() { + return CacheOutput::Unknown; + } let len = self.len_value.load(Ordering::Acquire); if len == NOT_LOADED_LEN { @@ -373,6 +397,7 @@ pub(crate) mod kind { &self, raw_env: Option<&'static [u8]>, ) -> Option { + let _publish = PublishEpoch(&self.epoch); // stored last, on every exit path: a reader that sees the epoch sees the value // The implementation is racy and allows two threads to both set the value at // the same time, as long as the value they are setting is the same. This is // difficult to write an assertion for since it requires the DEV path take a @@ -410,7 +435,8 @@ pub(crate) mod kind { // Cache type is emitted for every environment variable. // (In Rust, per-var statics give us per-var caches without distinct types.) pub(crate) struct Cache { - value: AtomicU8, // StoredType + value: AtomicU8, // StoredType + epoch: core::sync::atomic::AtomicU32, // snapshot restore epoch the value was loaded in; stale => reload } #[repr(u8)] @@ -426,11 +452,15 @@ pub(crate) mod kind { pub(crate) const fn new() -> Self { Self { value: AtomicU8::new(StoredType::Unknown as u8), + epoch: core::sync::atomic::AtomicU32::new(0), } } #[inline] pub(crate) fn get_cached(&self) -> Output { + if self.epoch.load(Ordering::Acquire) != crate::startup_snapshot::epoch() { + return CacheOutput::Unknown; + } // only ever stored from StoredType discriminants let cached: StoredType = match self.value.load(Ordering::Relaxed) { 1 => StoredType::NotSet, @@ -451,6 +481,7 @@ pub(crate) mod kind { #[inline] pub(crate) fn deser_and_invalidate(&self, raw_env: Option<&[u8]>) -> Option { + let _publish = PublishEpoch(&self.epoch); // stored last, on every exit path: a reader that sees the epoch sees the value let Some(raw_env) = raw_env else { self.value .store(StoredType::NotSet as u8, Ordering::Relaxed); @@ -544,6 +575,7 @@ pub(crate) mod kind { pub(crate) struct Cache { value: AtomicU64, ip: Input, + epoch: core::sync::atomic::AtomicU32, // snapshot restore epoch the value was loaded in; stale => reload } type StoredType = ValueType; @@ -558,11 +590,15 @@ pub(crate) mod kind { Self { value: AtomicU64::new(UNKNOWN_SENTINEL), ip, + epoch: core::sync::atomic::AtomicU32::new(0), } } #[inline] pub(crate) fn get_cached(&self) -> Output { + if self.epoch.load(Ordering::Acquire) != crate::startup_snapshot::epoch() { + return CacheOutput::Unknown; + } match self.value.load(Ordering::Relaxed) { UNKNOWN_SENTINEL => { crate::hint::cold(); @@ -575,6 +611,7 @@ pub(crate) mod kind { #[inline] pub(crate) fn deser_and_invalidate(&self, raw_env: Option<&[u8]>) -> Option { + let _publish = PublishEpoch(&self.epoch); // stored last, on every exit path: a reader that sees the epoch sees the value let Some(raw_env) = raw_env else { self.value.store(NOT_SET_SENTINEL, Ordering::Relaxed); return None; diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 7a6664d9e134..4951db3cf0ce 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -19,6 +19,7 @@ pub mod comptime_string_map; pub mod error; pub mod hint; pub mod result; +pub mod startup_snapshot; pub mod thread_id; pub mod tty; pub mod util; diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 3274e5c074cb..bc2e7d37f2ab 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -513,6 +513,35 @@ impl Source { } } } + + /// A process that resumed from a snapshot inherited the build's idea of its stdio (the flags in `bun_stdio_tty` have + /// already been recomputed for this process's descriptors by `bun_refresh_stdio_after_snapshot_restore`); rederive + /// what was computed from them and from the environment at startup. + #[cfg(not(target_arch = "wasm32"))] + pub fn refresh_stdio_after_snapshot_restore() { + STDOUT_DESCRIPTOR_TYPE.reset_for_snapshot_restore(); + STDERR_DESCRIPTOR_TYPE.reset_for_snapshot_restore(); + LAZY_COLOR_DEPTH.reset_for_snapshot_restore(); + let is_stdout_tty = stdio_tty_flag(1); + let is_stderr_tty = stdio_tty_flag(2); + if is_stdout_tty { + let _ = STDOUT_DESCRIPTOR_TYPE.set(OutputStreamDescriptor::Terminal); + } + if is_stderr_tty { + let _ = STDERR_DESCRIPTOR_TYPE.set(OutputStreamDescriptor::Terminal); + } + let enable_color = if Self::is_force_color() { + Some(true) + } else if Self::is_no_color() { + Some(false) + } else if Self::is_color_terminal() && (is_stdout_tty || is_stderr_tty) { + Some(true) + } else { + None + }; + ENABLE_ANSI_COLORS_STDOUT.store(enable_color.unwrap_or(is_stdout_tty), Ordering::Relaxed); + ENABLE_ANSI_COLORS_STDERR.store(enable_color.unwrap_or(is_stderr_tty), Ordering::Relaxed); + } } // ── Source::WindowsStdio ────────────────────────────────────────────────── diff --git a/src/bun_core/startup_snapshot.rs b/src/bun_core/startup_snapshot.rs new file mode 100644 index 000000000000..1ee68a8f0eb1 --- /dev/null +++ b/src/bun_core/startup_snapshot.rs @@ -0,0 +1,214 @@ +//! Snapshot process state: are we building a snapshot, and which restore epoch are we in. +use core::sync::atomic::{AtomicU32, Ordering}; + +// One epoch for the whole process (Rust, C++ and vendored C): the exported `bun_snapshot_epoch` symbol, defined here. +#[unsafe(no_mangle)] +pub static bun_snapshot_epoch: AtomicU32 = AtomicU32::new(0); +static BUILDING: AtomicU32 = AtomicU32::new(0); + +/// 0 in a normally booted process; bumped each time this process resumed from a snapshot. +#[inline] +pub fn epoch() -> u32 { + bun_snapshot_epoch.load(Ordering::Acquire) +} +#[inline] +pub fn restored() -> bool { + epoch() != 0 +} +/// Called once per restore (the C++ restore sequence has already bumped `bun_snapshot_epoch`). +pub fn did_restore() { + BUILDING.store(0, Ordering::Release); +} +/// True while this process is producing a snapshot: OS resources created now will not exist when the snapshot runs. +#[inline] +pub fn building() -> bool { + BUILDING.load(Ordering::Acquire) != 0 +} +pub fn set_building(on: bool) { + BUILDING.store(on as u32, Ordering::Release); +} + +/// A `Once` whose "done" state belongs to a process epoch: work that created OS state (threads, fds, ports) re-runs after a snapshot restore. +pub struct SnapshotOnce { + done_epoch: AtomicU32, // epoch+1 in which it last ran; 0 = never + lock: std::sync::Mutex<()>, +} +impl SnapshotOnce { + pub const fn new() -> Self { + Self { + done_epoch: AtomicU32::new(0), + lock: std::sync::Mutex::new(()), + } + } + #[inline] + pub fn is_done(&self) -> bool { + self.done_epoch.load(Ordering::Acquire) == epoch() + 1 + } + pub fn call(&self, f: impl FnOnce()) { + if self.is_done() { + return; + } + let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + if self.is_done() { + return; + } + f(); + self.done_epoch.store(epoch() + 1, Ordering::Release); + } +} + +/// A cached value derived from the launch context (argv, env, cwd, uid, terminal…), recomputed after a restore; use it instead of `Once`/`OnceLock` for anything read from the OS and cached. +pub struct ProcessDerived { + epoch_plus_one: AtomicU32, + lock: std::sync::Mutex<()>, + ptr: core::sync::atomic::AtomicPtr, +} +impl ProcessDerived { + pub const fn new() -> Self { + Self { + epoch_plus_one: AtomicU32::new(0), + lock: std::sync::Mutex::new(()), + ptr: core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()), + } + } + /// The current process's value: `init` runs on first use after each restore; earlier values are leaked so references stay valid. + pub fn get(&'static self, init: impl FnOnce() -> T) -> &'static T { + let want = epoch() + 1; + if self.epoch_plus_one.load(Ordering::Acquire) != want { + let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + if self.epoch_plus_one.load(Ordering::Acquire) != want { + self.ptr + .store(Box::leak(Box::new(init())), Ordering::Release); + self.epoch_plus_one.store(want, Ordering::Release); + } + } + // SAFETY: non-null (stored above for this epoch) and leaked for the process lifetime. + unsafe { &*self.ptr.load(Ordering::Acquire) } + } + /// True if a value has been computed for the current process. + pub fn is_current(&self) -> bool { + self.epoch_plus_one.load(Ordering::Acquire) == epoch() + 1 + } +} + +/// What the snapshot run may touch on the build machine (`--snapshot-io`); every allowed use is recorded and reported when the snapshot is written. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum IoPolicy { + Strict, + Local, + /// BUN_STARTUP_SNAPSHOT_IO=network: the network too — its answers are frozen into every launch — still recorded and reported. + Network, +} +pub fn io_policy() -> IoPolicy { + match crate::env_var::BUN_STARTUP_SNAPSHOT_IO.get() { + Some(b"local") => IoPolicy::Local, + Some(b"network") => IoPolicy::Network, + _ => IoPolicy::Strict, + } +} +/// Whether the policy admits an operation of this class, which the gate then records. +pub fn io_allowed(kind: &str) -> bool { + let local_class = matches!( + kind, + "node:fs" + | "Bun.file" + | "Bun.write" + | "Bun.spawn" + | "Bun.listen" + | "Bun.serve" + | "Bun.udpSocket" + | "dns" + ); + match io_policy() { + IoPolicy::Strict => false, + IoPolicy::Local => local_class, + IoPolicy::Network => true, + } +} + +/// Local I/O performed while building, keyed by (kind, JS call site) -> count. Only ever touched on the JS thread of the builder. +static LOCAL_IO_AUDIT: std::sync::Mutex, u32)>> = + std::sync::Mutex::new(Vec::new()); +pub fn note_local_io(kind: &'static str, site: Vec) { + let mut audit = LOCAL_IO_AUDIT.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = audit.iter_mut().find(|(k, s, _)| *k == kind && *s == site) { + entry.2 += 1; + } else { + audit.push((kind, site, 1)); + } +} +static STDIO_NOTES: std::sync::Mutex)>> = std::sync::Mutex::new(Vec::new()); +/// A `process.std*` stream was created during the snapshot run: whatever the app derived from it (isTTY, colors) describes the build's descriptors. +pub fn note_stdio_stream(fd: i32, site: Vec) { + STDIO_NOTES + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((fd, site)); +} +pub fn take_stdio_notes() -> Vec<(i32, Vec)> { + std::mem::take(&mut *STDIO_NOTES.lock().unwrap_or_else(|e| e.into_inner())) +} +/// The audit, most frequent first; empty unless the build did local I/O. +pub fn take_local_io_audit() -> Vec<(&'static str, Vec, u32)> { + let mut audit = std::mem::take(&mut *LOCAL_IO_AUDIT.lock().unwrap_or_else(|e| e.into_inner())); + audit.sort_by_key(|entry| core::cmp::Reverse(entry.2)); + audit +} + +static SNAPSHOT_REQUESTED: AtomicU32 = AtomicU32::new(0); +static SNAPSHOT_PATH: std::sync::Mutex>> = std::sync::Mutex::new(None); +/// Ask the main run loop to leave JS entirely and take the snapshot at top level (caller then unwinds via a termination exception). +pub fn request_snapshot(path: &[u8]) { + *SNAPSHOT_PATH.lock().unwrap_or_else(|e| e.into_inner()) = Some(path.to_owned()); + SNAPSHOT_REQUESTED.store(1, Ordering::Release); +} +#[inline] +pub fn snapshot_requested() -> bool { + SNAPSHOT_REQUESTED.load(Ordering::Acquire) != 0 +} +static SNAPSHOT_IN_PROGRESS: AtomicU32 = AtomicU32::new(0); +/// Set once the runtime is draining the process for the snapshot; later `take()` calls only contribute their options. +pub fn set_snapshot_in_progress() { + SNAPSHOT_IN_PROGRESS.store(1, Ordering::Release); +} +pub fn snapshot_in_progress() -> bool { + SNAPSHOT_IN_PROGRESS.load(Ordering::Acquire) != 0 +} +pub fn take_snapshot_request() -> Option> { + if SNAPSHOT_REQUESTED.swap(0, Ordering::AcqRel) == 0 { + return None; + } + SNAPSHOT_PATH + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + +/// What `Bun.startupSnapshot.take()` does about timers that are still armed when the process goes quiet. +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum StartupSnapshotTimers { + /// Armed timers keep the process from being snapshotted (the default: the app is expected to clear them itself). + Refuse = 0, + /// Timers survive the snapshot; their deadlines are re-based onto the restoring process's clock. + Keep = 1, + /// The runtime drops every armed timer as part of taking the snapshot (the app re-arms what it needs after restore). + Cancel = 2, +} +static SNAPSHOT_TIMERS: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0); +pub fn set_snapshot_timers(mode: StartupSnapshotTimers) { + SNAPSHOT_TIMERS.store(mode as u8, Ordering::Release); +} +pub fn snapshot_timers() -> StartupSnapshotTimers { + match SNAPSHOT_TIMERS.load(Ordering::Acquire) { + 1 => StartupSnapshotTimers::Keep, + 2 => StartupSnapshotTimers::Cancel, + _ => StartupSnapshotTimers::Refuse, + } +} + +/// Monotonic (sec, nsec) at the moment the snapshot was frozen; lives in __DATA so the restored process can compute how far its own clock is from it. +pub static SNAPSHOT_MONOTONIC: [core::sync::atomic::AtomicI64; 2] = [ + core::sync::atomic::AtomicI64::new(0), + core::sync::atomic::AtomicI64::new(0), +]; diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 705ae0158e99..b02abe64d68c 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -2458,6 +2458,16 @@ impl Once { { *self.get_or_init(f) } + /// Back to uninitialized, so the next use recomputes. Only for `Copy` payloads (nothing to drop) and only while no other + /// thread can be using the value: a process that just resumed from a snapshot recomputing what it inherited from the build. + pub fn reset_for_snapshot_restore(&self) + where + T: Copy, + { + self.state + .store(ONCE_UNINIT, core::sync::atomic::Ordering::Release); + } + /// Fast path: already initialised? #[inline(always)] pub fn get(&self) -> Option<&T> { @@ -2761,8 +2771,11 @@ fn os_entropy(bytes: &mut [u8]) { // Memoized into a process-lifetime // static buffer; thread-safe via `Once`. Returns a `&'static ZStr`. pub fn self_exe_path() -> crate::CrateResult<&'static ZStr> { - static CELL: Once> = Once::new(); - let r = CELL.get_or_init(|| { + // Per process, not once: a snapshot built at one path is routinely restored by the same executable at another (a compiled + // executable is built in one place and deployed to another), and process.execPath must say where this one is. + static CELL: crate::startup_snapshot::ProcessDerived> = + crate::startup_snapshot::ProcessDerived::new(); + let r = CELL.get(|| { let path = std::env::current_exe().map_err(|_| crate::CrateError::Unexpected)?; // Symlink resolution: Rust's // `current_exe()` already resolves on Linux (`readlink /proc/self/exe`), @@ -3544,7 +3557,12 @@ pub fn fast_random() -> u64 { use core::cell::Cell; use core::sync::atomic::{AtomicU64, Ordering as O}; static SEED: AtomicU64 = AtomicU64::new(0); + static SEED_EPOCH: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); fn random_seed() -> u64 { + let epoch = crate::startup_snapshot::epoch(); + if SEED_EPOCH.swap(epoch, O::Relaxed) != epoch { + SEED.store(0, O::Relaxed); // seed cached by the snapshot builder: draw a fresh one here + } let mut v = SEED.load(O::Relaxed); while v == 0 { // Should also apply to canary builds, but bun_core has no `canary` @@ -3564,14 +3582,16 @@ pub fn fast_random() -> u64 { v } thread_local! { - static PRNG: Cell> = const { Cell::new(None) }; + static PRNG: Cell> = const { Cell::new(None) }; } PRNG.with(|p| { - let mut prng = p - .take() - .unwrap_or_else(|| rand::DefaultPrng::init(random_seed())); + let epoch = crate::startup_snapshot::epoch(); + let mut prng = match p.take() { + Some((e, prng)) if e == epoch => prng, + _ => rand::DefaultPrng::init(random_seed()), // first use, or first use since a snapshot restore (the snapshotd stream is the builder's) + }; let v = prng.next_u64(); - p.set(Some(prng)); + p.set(Some((epoch, prng))); v }) } @@ -3726,10 +3746,16 @@ pub use bun_alloc::secure_zero; // `Argv` wrapper so call sites can use it both as a slice (`.get(0)`, // `.iter()`, `.len()`, `.as_slice()`) and as an `IntoIterator` // for `for arg in argv()`. -static ARGV_STORAGE: Once> = Once::new(); -static ARGV_VIEW: Once> = Once::new(); -static ARGV: RacyCell<&'static [&'static ZStr]> = RacyCell::new(&[]); -static ARGV_INIT: std::sync::Once = std::sync::Once::new(); +// Launch-context derived (recomputed after a snapshot restore — see `snapshot::ProcessDerived`). +static ARGV_STORAGE: crate::startup_snapshot::ProcessDerived> = + crate::startup_snapshot::ProcessDerived::new(); +struct ArgvView(RacyCell<&'static [&'static ZStr]>); +// SAFETY: the view is written during single-threaded startup / restore adoption only (see `set_argv`). +unsafe impl Sync for ArgvView {} +// SAFETY: as above; the view holds only `'static` data. +unsafe impl Send for ArgvView {} +static ARGV: crate::startup_snapshot::ProcessDerived = + crate::startup_snapshot::ProcessDerived::new(); /// Raw `(argc, argv)` as passed to `main` by the C runtime. Captured by /// [`init_argv`] before any other code runs. On glibc / macOS / Windows, @@ -3758,6 +3784,28 @@ pub unsafe fn init_argv(argc: core::ffi::c_int, argv: *const *const core::ffi::c OS_ARGV.store(argv.cast_mut(), core::sync::atomic::Ordering::Relaxed); } +/// The raw launch inputs `main` received. A snapshot restore overlays this crate's statics with the builder's, +/// so the restore sequence reads these before the overlay and hands them back after (`bun_launch_context_*`). +#[repr(C)] +pub struct LaunchContext { + pub argc: usize, + pub argv: *const *const core::ffi::c_char, +} +#[unsafe(no_mangle)] +pub extern "C" fn bun_launch_context_capture(out: &mut LaunchContext) { + out.argc = OS_ARGC.load(core::sync::atomic::Ordering::Relaxed); + out.argv = OS_ARGV + .load(core::sync::atomic::Ordering::Relaxed) + .cast_const(); +} +/// # Safety +/// `ctx` must come from `bun_launch_context_capture` in this same process. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bun_launch_context_restore(ctx: &LaunchContext) { + OS_ARGC.store(ctx.argc, core::sync::atomic::Ordering::Relaxed); + OS_ARGV.store(ctx.argv.cast_mut(), core::sync::atomic::Ordering::Relaxed); +} + /// Kernel-provided argv slice if [`init_argv`] was called, else `None`. #[inline] #[cfg(not(windows))] @@ -3773,7 +3821,7 @@ fn raw_os_argv() -> Option<&'static [*const core::ffi::c_char]> { } fn argv_storage() -> &'static [ZBox] { - ARGV_STORAGE.get_or_init(|| { + ARGV_STORAGE.get(|| { // Windows: the CRT-provided `char** argv` captured by `init_argv` is // ANSI-encoded (CP_ACP) — `WideCharToMultiByte` lossy-converts the // UTF-16 command line, replacing unrepresentable code points with `?`. @@ -3830,29 +3878,78 @@ fn argv_storage() -> &'static [ZBox] { }) } +/// Options a `--compile`d executable carries (its `compile_exec_argv`); spliced after argv[0] like BUN_OPTIONS. +/// Invariant for the executable, so a plain static (set once by `boot_standalone`, before `argv()` is derived). +static COMPILE_EXEC_ARGV: RacyCell<&'static [u8]> = RacyCell::new(b""); +static COMPILE_EXEC_ARGC: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); +/// # Safety +/// Single-threaded startup, before the first `argv()` read that should see the splice. +pub unsafe fn set_compile_exec_argv(opts: &'static [u8]) { + // SAFETY: see fn doc. + unsafe { COMPILE_EXEC_ARGV.write(opts) }; +} +/// Number of argv tokens that came from the executable's `compile_exec_argv` (valid after `argv()` was read). +pub fn compile_exec_argc() -> usize { + let _ = argv_view(); + COMPILE_EXEC_ARGC.load(core::sync::atomic::Ordering::Relaxed) +} +/// Where the user's own arguments begin in `argv()`, kept relative to the spliced-options prefix (argv[0] + compile-time +/// options + `BUN_OPTIONS` tokens): that prefix is recomputed with `argv()` itself after a snapshot restore, and the launching +/// process may carry a different `BUN_OPTIONS` than the one that built the snapshot, so an absolute index would slice wrong. +static PASSTHROUGH_AFTER_PREFIX: core::sync::atomic::AtomicUsize = + core::sync::atomic::AtomicUsize::new(PASSTHROUGH_DISABLED); +const PASSTHROUGH_DISABLED: usize = usize::MAX; +fn spliced_prefix_len() -> usize { + let _ = argv_view(); + 1 + COMPILE_EXEC_ARGC.load(core::sync::atomic::Ordering::Relaxed) + + BUN_OPTIONS_ARGC.load(core::sync::atomic::Ordering::Relaxed) +} +/// `offset` is an absolute index into the current `argv()`; 0 disables (the arguments are not a verbatim argv tail). +pub fn set_standalone_passthrough_offset(offset: usize) { + // (name kept: also set for plain `bun entry args…` when the args are a verbatim argv tail) + let value = if offset == 0 { + PASSTHROUGH_DISABLED + } else { + offset.saturating_sub(spliced_prefix_len()) + }; + PASSTHROUGH_AFTER_PREFIX.store(value, core::sync::atomic::Ordering::Relaxed); +} +/// Absolute index into the current process's `argv()` (0 = disabled). +pub fn standalone_passthrough_offset() -> usize { + match PASSTHROUGH_AFTER_PREFIX.load(core::sync::atomic::Ordering::Relaxed) { + PASSTHROUGH_DISABLED => 0, + after_prefix => (spliced_prefix_len() + after_prefix).min(argv_view().len()), + } +} + #[cold] #[inline(never)] -fn argv_view_init() { +fn argv_view_init() -> ArgvView { let storage: &'static [ZBox] = argv_storage(); - // ARGV_STORAGE is process-static via `Once`; `as_zstr` borrows for `'static`. let mut view: Vec<&'static ZStr> = storage.iter().map(ZBox::as_zstr).collect(); - // Splice BUN_OPTIONS tokens after argv[0]. + // Final order is [argv0, compile-time options, BUN_OPTIONS tokens, the user's arguments], so BUN_OPTIONS overrides what + // was compiled in. `append_options_env` inserts at position 1, so the group that must end up leftmost is spliced last. + // Counts are stored even when a group is absent: this runs again in a process restored from a snapshot, whose + // environment may lack a BUN_OPTIONS the building process had. + let before = view.len(); if let Some(opts) = crate::env_var::BUN_OPTIONS.get() { - let original_len = view.len(); append_options_env::<&'static ZStr>(opts, &mut view); - set_bun_options_argc(view.len() - original_len); } - let view: &'static [&'static ZStr] = ARGV_VIEW.get_or_init(move || view); - // SAFETY: single-threaded lazy init guarded by Once. - unsafe { ARGV.write(view) }; + set_bun_options_argc(view.len() - before); + // SAFETY: written once during single-threaded startup. + let compile_opts = unsafe { COMPILE_EXEC_ARGV.read() }; + let before = view.len(); + if !compile_opts.is_empty() { + append_options_env::<&'static ZStr>(compile_opts, &mut view); + } + COMPILE_EXEC_ARGC.store(view.len() - before, core::sync::atomic::Ordering::Relaxed); + ArgvView(RacyCell::new(Vec::leak(view))) } #[inline] fn argv_view() -> &'static [&'static ZStr] { - ARGV_INIT.call_once(argv_view_init); - // SAFETY: ARGV is a Copy fat-pointer; only mutated via `set_argv` during - // single-threaded startup or by the Once above. - unsafe { ARGV.read() } + // SAFETY: the RacyCell is only written by `set_argv` during single-threaded startup. + unsafe { ARGV.get(argv_view_init).0.read() } } #[derive(Clone, Copy)] @@ -4109,10 +4206,8 @@ pub fn append_options_env(env: &[u8], args: &mut Vec) { /// Caller must ensure no concurrent reads of `argv()` are in flight. #[inline] pub unsafe fn set_argv(v: &'static [&'static ZStr]) { - // Prevent the lazy OS-argv init from later clobbering a manually-set view. - ARGV_INIT.call_once(|| {}); // SAFETY: see fn doc — single-threaded startup. - unsafe { ARGV.write(v) }; + unsafe { ARGV.get(argv_view_init).0.write(v) }; } /// Park an owned argv `Vec` in process-static storage and return the @@ -4509,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/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 0ae6c8e6a18d..9a89cb38fb18 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -126,6 +126,8 @@ pub struct Loader { pub quiet: bool, pub(crate) did_load_process: bool, + /// Keys that came from the OS environment (so a snapshot restore can drop the builder's and load this process's). + process_keys: Vec>, pub(crate) reject_unauthorized: Cell>, // Local POD mirror of `bun_s3_signing::S3Credentials` — see type doc above. @@ -575,11 +577,31 @@ impl Loader { custom_files_loaded: StringArrayHashMap::default(), quiet: false, did_load_process: false, + process_keys: Vec::new(), reject_unauthorized: Cell::new(None), aws_credentials: None, } } + /// snapshot restore: the map holds the *builder's* environment. Drop those entries and load this process's environ. + pub fn reload_process_after_snapshot_restore(&mut self) -> Result<(), AllocError> { + for key in core::mem::take(&mut self.process_keys) { + self.map.remove(&key); + } + self.did_load_process = false; + // Derived lazily from the environment being replaced: re-derived from the new one on next use. The credentials in + // particular must not outlive the environment they came from. + self.reject_unauthorized.set(None); + self.aws_credentials = None; + self.load_process()?; + for (key, value) in core::mem::take(&mut self.map.shadowed_by_process) { + if self.map.get(&key).is_none() { + self.map.put(&key, &value)?; + } + } + Ok(()) + } + pub fn load_process(&mut self) -> Result<(), AllocError> { if self.did_load_process { return Ok(()); @@ -595,10 +617,12 @@ impl Loader { let value = &env[i as usize + 1..]; if !key.is_empty() { self.map.put(key, value)?; + self.process_keys.push(Box::from(key)); } } else { if !env.is_empty() { self.map.put(env, b"")?; + self.process_keys.push(Box::from(env)); } } } @@ -1252,6 +1276,7 @@ impl<'a> Parser<'a> { map: &mut Map, ) -> Result<(), AllocError> { let mut count = map.map.count(); + let shadowed_start = map.shadowed_by_process.len(); while self.pos < self.src.len() { let Some(key) = self.parse_key::() else { self.skip_line(); @@ -1266,6 +1291,9 @@ impl<'a> Parser<'a> { // Allow keys defined later in the same file to override keys defined earlier // https://github.com/oven-sh/bun/issues/1262 if !OVERRIDE { + if bun_core::startup_snapshot::building() { + map.shadowed_by_process.push((Box::from(key), value_owned)); + } continue; } } @@ -1289,6 +1317,29 @@ impl<'a> Parser<'a> { } idx += 1; } + // A key repeated in one file: the later line wins at boot (#1262), so keep only the last stash of each key from this file. + let mut i = shadowed_start; + while i < map.shadowed_by_process.len() { + let key = map.shadowed_by_process[i].0.clone(); + let last = map + .shadowed_by_process + .iter() + .rposition(|(k, _)| *k == key) + .unwrap(); + if last != i { + map.shadowed_by_process.remove(i); + } else { + i += 1; + } + } + let mut idx = shadowed_start; + while idx < map.shadowed_by_process.len() { + let current: Box<[u8]> = map.shadowed_by_process[idx].1.clone(); + if let Some(expanded) = self.expand_value(map, ¤t)? { + map.shadowed_by_process[idx].1 = Box::from(expanded); + } + idx += 1; + } count = 0; } let _ = count; @@ -1334,6 +1385,8 @@ pub type HashTable = bun_collections::CaseInsensitiveAsciiStringArrayHashMap, Box<[u8]>)>, } impl Default for Map { @@ -1436,8 +1489,13 @@ impl Map { #[inline] pub(crate) fn init() -> Map { + Self::with_table(HashTable::default()) + } + + pub fn with_table(map: HashTable) -> Map { Map { - map: HashTable::default(), + map, + shadowed_by_process: Vec::new(), } } @@ -1522,9 +1580,7 @@ impl Map { pub fn clone_with_allocator(&self) -> Result { // allocator param dropped — global mimalloc - Ok(Map { - map: self.map.clone()?, - }) + Ok(Map::with_table(self.map.clone()?)) } } 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/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index e46ff76410e9..b81b76576bee 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -67,6 +67,10 @@ unsafe impl bun_threading::Linked for AsyncHTTP<'static> { } pub(crate) static ACTIVE_REQUESTS_COUNT: AtomicUsize = AtomicUsize::new(0); +/// Requests the HTTP thread is currently working on (for the snapshot quiescence gate). +pub fn active_requests_count() -> usize { + ACTIVE_REQUESTS_COUNT.load(Ordering::Relaxed) +} pub static MAX_SIMULTANEOUS_REQUESTS: AtomicUsize = AtomicUsize::new(256); // ────────────────────────────────────────────────────────────────────────── diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index eaec4535e8d5..e0aa62bbfa7d 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -1233,9 +1233,8 @@ use core::cell::Cell; mod _event_loop_draft { use super::*; - use std::sync::Once; - - static INIT_ONCE: Once = Once::new(); + static INIT_ONCE: bun_core::startup_snapshot::SnapshotOnce = + bun_core::startup_snapshot::SnapshotOnce::new(); // Note: `Builder::spawn` allocates an `Arc` (48 B) // shared between the `JoinHandle` and the new thread's TLS `current()`. // Dropping the handle leaves the only strong ref inside the spawned @@ -1249,7 +1248,7 @@ mod _event_loop_draft { std::sync::OnceLock::new(); pub(super) fn init(opts: &InitOpts) { - INIT_ONCE.call_once(|| init_once(opts)); + INIT_ONCE.call(|| init_once(opts)); } fn init_once(opts: &InitOpts) { @@ -1417,6 +1416,13 @@ static SHUTDOWN_DONE: (bun_threading::Guarded, bun_threading::Condvar) = ( bun_threading::Condvar::new(), ); +/// After the freeze-time shutdown: the snapshot must not carry "shutting down", or the thread `init` starts again in a +/// restored process (its `SnapshotOnce` re-runs there) would exit at once; and that process's own exit must still drain. +pub fn reset_shutdown_state_for_snapshot() { + SHUTDOWN_REQUESTED.store(false, Ordering::Release); + *SHUTDOWN_DONE.0.lock() = false; +} + /// Called from `bun_jsc::VirtualMachine::global_exit()` on the JS thread, /// before `~VM`. Asks the HTTP daemon thread to reclaim every in-flight /// `ThreadlocalAsyncHTTP` box and waits (with a short timeout) for it to ack. diff --git a/src/http/lib.rs b/src/http/lib.rs index 5e27bd2f4e12..53b01118787c 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -242,6 +242,7 @@ impl Default for Flags { // ───────────────────────────── globals ───────────────────────────── pub(crate) static ASYNC_HTTP_ID_MONOTONIC: AtomicU32 = AtomicU32::new(0); +pub use crate::async_http::active_requests_count; /// Set once at startup from `--experimental-http2-fetch` (before the HTTP /// thread spawns) and then only read on that thread. diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index ce6729ca685b..c05131d65b51 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -78,7 +78,9 @@ impl IniTestingAPIs { )?; } - env_storage.insert(dotenv::Loader::init_with_map(dotenv::Map { map: envmap })) + env_storage.insert(dotenv::Loader::init_with_map(dotenv::Map::with_table( + envmap, + ))) }; let mut install = Box::new(BunInstall::default()); diff --git a/src/io/ParentDeathWatchdog.rs b/src/io/ParentDeathWatchdog.rs index fe05cc8c709a..a32bfafdce86 100644 --- a/src/io/ParentDeathWatchdog.rs +++ b/src/io/ParentDeathWatchdog.rs @@ -291,6 +291,21 @@ pub fn enable() { #[inline] pub fn ensure_kill_on_close_job() {} +/// A process restored from a snapshot inherited the builder's parent pid and (on macOS) the builder's registered watch, both +/// meaningless here, and its main thread is not yet marked as the arming thread: watch this process's own parent instead. The inherited poll is left alone by the restore (see +/// `FilePoll::rearm_after_snapshot_restore`); it never kept the loop alive and nothing will ever fire it. +pub fn reinstall_after_snapshot_restore(handle: EventLoopCtx) { + if !ENABLED.load(Ordering::Relaxed) { + return; + } + // SAFETY: getppid cannot fail. + ORIGINAL_PPID.store(unsafe { libc::getppid() }, Ordering::Relaxed); + bun_spawn_sys::pdeathsig::readopt_arming_thread(); + #[cfg(target_os = "macos")] + EVENT_LOOP_INSTALLED.store(false, Ordering::Relaxed); + install_on_event_loop(handle); +} + /// Register `EVFILT_PROC`/`NOTE_EXIT` for the original parent on the main /// event loop's kqueue. Called from `VirtualMachine.init` once the uws loop is /// up. macOS-only; no-op elsewhere and on subsequent calls. diff --git a/src/io/lib.rs b/src/io/lib.rs index e17d52e9384b..b8d251583c98 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -264,6 +264,8 @@ pub mod parent_death_watchdog { #[inline] pub fn install_on_event_loop(_handle: EventLoopCtx) {} + #[inline] + pub fn reinstall_after_snapshot_restore(_handle: EventLoopCtx) {} } pub use parent_death_watchdog as ParentDeathWatchdog; @@ -444,6 +446,8 @@ impl EventLoopCtx { } #[cfg(not(windows))] pub use posix_event_loop::Store; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] +pub use posix_event_loop::dispatch_snapshot_hangups; #[cfg(windows)] pub use windows_event_loop::Store; @@ -786,6 +790,16 @@ static LOOP: bun_core::ThreadCell> = #[cfg(not(windows))] static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new(); +/// Snapshot freeze: the "IO Watcher" thread has no stop protocol, so a snapshot is refused while it exists. +#[cfg(not(windows))] +pub fn io_watcher_snapshot_blocker() -> Option<&'static str> { + ONCE.get().map(|_| "the file I/O watcher thread is running (Bun.file()/Bun.write() on a pipe or terminal started it) — a snapshot cannot contain a thread") +} +#[cfg(windows)] +pub fn io_watcher_snapshot_blocker() -> Option<&'static str> { + None +} + impl IoRequestLoop { #[cfg(not(windows))] fn load() { diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index 86f853834a59..ddf56c98feb9 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -315,8 +315,64 @@ impl Default for FilePoll { } } +/// Outcome of `FilePoll::rearm_after_snapshot_restore`. +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] +enum StartupSnapshotRearm { + Untouched, + Rearmed, + HungUp, +} + #[cfg(not(windows))] impl FilePoll { + /// snapshot restore (`Store::rearm_after_snapshot_restore`): re-add this poll to the new kqueue if its fd still means the same + /// thing in this process, otherwise mark it hung up so the owner hears about it once the app has been told to restore. + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + fn rearm_after_snapshot_restore(&mut self, loop_: &mut Loop) -> StartupSnapshotRearm { + if self.fd == INVALID_FD + || !self.flags.contains(Flags::WasEverRegistered) + || self.flags.contains(Flags::Closed) + { + return StartupSnapshotRearm::Untouched; + } + let want = if self.flags.contains(Flags::PollReadable) { + Flags::Readable + } else if self.flags.contains(Flags::PollWritable) { + Flags::Writable + } else { + // A process poll's "fd" is a pid from the building process; the one such poll a snapshot can hold (the parent-death + // watchdog: children block the freeze) is re-created for this process's parent by its owner, not re-armed or hung up. + return StartupSnapshotRearm::Untouched; + }; + // Only fds the restore re-seated mean the same thing in this process: stdio (dup'd from the launcher onto the + // builder's numbers) and the controlling tty. Every other number is stale or parked on /dev/null. + // SAFETY: probing an integer fd. + let reseated = self.fd.native() <= 2 || unsafe { libc::isatty(self.fd.native()) } == 1; + if reseated { + let one_shot = + self.flags.contains(Flags::OneShot) || self.flags.contains(Flags::NeedsRearm); + self.flags.remove(Flags::NeedsRearm); + // Nothing is registered in this process's epoll/kqueue yet: registering must add, not modify. + self.flags + .remove_all(Flags::PollReadable | Flags::PollWritable | Flags::PollProcess); + let one_shot = if one_shot { + OneShotFlag::OneShot + } else { + OneShotFlag::None + }; + if self + .register_with_fd(loop_, want, one_shot, self.fd) + .is_ok() + { + return StartupSnapshotRearm::Rearmed; + } + } else { + self.flags + .remove_all(Flags::PollReadable | Flags::PollWritable | Flags::PollProcess); + } + self.flags.insert(Flags::Hup); + StartupSnapshotRearm::HungUp + } fn update_flags(&mut self, updated: FlagsSet) { let mut flags = self.flags; flags.remove(Flags::Readable); @@ -1319,15 +1375,63 @@ pub struct Store { hive: FilePollHive, pending_free_head: *mut FilePoll, pending_free_tail: *mut FilePoll, + /// Polls whose fd did not survive a snapshot restore; their hangups are delivered from the event loop once the + /// process is fully adopted and the app has heard 'restore', not from inside the restore itself. + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + snapshot_hangups: Vec<*mut FilePoll>, } #[cfg(not(windows))] impl Store { + /// Live polls in the inline hive. At `HIVE_SIZE` there may also be overflow polls, which nothing can enumerate after a + /// restore, so a freeze at that point is refused (`snapshot_blockers`). + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + pub fn inline_hive_is_full(&self) -> bool { + let mut it = self.hive.hive.used.iter_set(); + let mut n = 0usize; + while it.next().is_some() { + n += 1; + } + n >= HIVE_SIZE + } + + /// snapshot restore: the kqueue is new and every knote from the build process is gone. Re-add polls whose fd still exists; hang up the rest. + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + pub fn rearm_after_snapshot_restore(&mut self, loop_: &mut Loop) -> (usize, usize) { + let (mut rearmed, mut hung_up) = (0usize, 0usize); + let mut polls: Vec<*mut FilePoll> = Vec::new(); + let mut it = self.hive.hive.used.iter_set(); + while let Some(i) = it.next() { + polls.push(self.hive.hive.at(i as u16)); + } + for poll_ptr in polls { + // SAFETY: the slot is marked used in the hive and nothing runs concurrently during restore; the call is the + // only access through this pointer. + match unsafe { (*poll_ptr).rearm_after_snapshot_restore(loop_) } { + StartupSnapshotRearm::Untouched => {} + StartupSnapshotRearm::Rearmed => rearmed += 1, + StartupSnapshotRearm::HungUp => { + self.snapshot_hangups.push(poll_ptr); + hung_up += 1; + } + } + } + (rearmed, hung_up) + } + + /// The polls whose fds did not survive the restore, handed out so they can be delivered with no borrow of the store live. + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + pub fn take_snapshot_hangups(&mut self) -> Vec<*mut FilePoll> { + core::mem::take(&mut self.snapshot_hangups) + } + pub fn init() -> Store { Store { hive: FilePollHive::init(), pending_free_head: ptr::null_mut(), pending_free_tail: ptr::null_mut(), + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + snapshot_hangups: Vec::new(), } } @@ -1413,6 +1517,29 @@ impl Store { } } +/// Deliver the hangups collected at restore. Not a `Store` method on purpose (see [`Store::take_snapshot_hangups`]). +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] +pub fn dispatch_snapshot_hangups(pending: Vec<*mut FilePoll>) -> usize { + let mut delivered = 0usize; + for poll_ptr in pending { + // SAFETY: collected from used hive slots during restore; a slot is only recycled through the deferred-free list, which + // does not run until the loop ticks. A poll closed by an earlier hangup's owner is still readable and reads as closed. + let still_hung_up = unsafe { + let poll = &*poll_ptr; + poll.fd != INVALID_FD + && !poll.flags.contains(Flags::Closed) + && poll.flags.contains(Flags::Hup) + }; + if !still_hung_up { + continue; + } + // SAFETY: as above; the owner may re-enter the store freely, since nothing of it is borrowed here. + unsafe { (*poll_ptr).on_update(0) }; + delivered += 1; + } + delivered +} + // ────────────────────────────────────────────────────────────────────────── // onTick (exported) // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/AsyncModule.rs b/src/jsc/AsyncModule.rs index 5bfc14267a0c..3c2604475352 100644 --- a/src/jsc/AsyncModule.rs +++ b/src/jsc/AsyncModule.rs @@ -1253,9 +1253,7 @@ impl AsyncModule { // swap the buffer out and write it back via the `_writeback` // guard — same observable effect (the thread-local's buffer is // reused). Matches RuntimeTranspilerStore.rs. - let mut printer_ptr = crate::virtual_machine::SOURCE_CODE_PRINTER - .get() - .expect("source_code_printer not initialized"); + let mut printer_ptr = crate::virtual_machine::source_code_printer(); // SAFETY: thread-local owns the leaked Box; only this thread touches it. let mut printer = core::mem::replace( unsafe { printer_ptr.as_mut() }, diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 032eacd0c94e..179ed65328b6 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -312,6 +312,56 @@ impl JSGlobalObject { JSValue::from_encoded(std::ptr::from_ref::(self) as usize) } + /// I/O whose result would be baked into a snapshot is refused while one is being built. Under `IoPolicy::Local`, + /// what only touches this machine (files, subprocesses, local listeners, the resolver) is allowed and recorded for the + /// report the snapshot writer prints; network use is refused regardless. + pub fn throw_disabled_in_snapshot_error_if_needed( + &self, + what: &'static str, + ) -> Result<(), JsError> { + if !bun_core::startup_snapshot::building() { + return Ok(()); + } + if bun_core::startup_snapshot::io_allowed(what) { + bun_core::startup_snapshot::note_local_io(what, self.current_call_site_for_report()); + return Ok(()); + } + Err(self.throw_invalid_arguments(format_args!( + "{what} is not available while building a snapshot: its result would be frozen into every launch. Do it after restore (process.on('restore')), or allow it for the build with --snapshot-io, which reports every use" + ))) + } + + /// The innermost JS frames as an error stack would print them (source maps applied), for the build-time reports. + fn current_call_site_for_report(&self) -> Vec { + let err = self.create_error_instance(format_args!("")); + let Ok(Some(stack)) = err.get(self, "stack") else { + self.clear_exception(); // a user Error.prepareStackTrace may have thrown; the report is best-effort + return Vec::new(); + }; + let Ok(stack) = stack.to_bun_string(self) else { + self.clear_exception(); + return Vec::new(); + }; + let utf8 = stack.to_utf8(); + let mut site = Vec::new(); + // Skip the message line; keep the four innermost frames. + for line in bun_core::strings::split(utf8.slice(), b"\n") + .skip(1) + .take(4) + { + let line = line.trim_ascii(); + if line.is_empty() { + continue; + } + if !site.is_empty() { + site.push(b'\n'); + } + site.extend_from_slice(b" "); + site.extend_from_slice(line); + } + site + } + pub fn throw_invalid_arguments(&self, args: Arguments<'_>) -> JsError { let err = self.to_invalid_arguments(args); self.throw_value(err) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0ee8a9c4075e..f3139d690f0a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -286,7 +286,7 @@ pub struct VirtualMachine { pub argv: Vec>, pub origin_timer: std::time::Instant, - pub(crate) origin_timestamp: u64, + pub origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). pub overridden_performance_now: Option, pub(crate) macro_event_loop: EventLoop, @@ -296,7 +296,7 @@ pub struct VirtualMachine { pub(crate) ref_strings: crate::ref_string::Map, pub(crate) ref_strings_mutex: bun_threading::Mutex, - pub(crate) active_tasks: usize, + pub active_tasks: usize, pub rare_data: Option>, pub proxy_env_storage: crate::rare_data::ProxyEnvStorage, @@ -464,6 +464,12 @@ impl VMHolder { pub(crate) fn set_vm(vm: Option<*mut VirtualMachine>) { VM.set(vm) } + /// Snapshot: install an existing VM (from the snapshot's static) as this thread's VM. + pub(crate) fn adopt(vm: *mut VirtualMachine) { + VM.set(Some(vm)); + // SAFETY: `vm` is the snapshot's live main-thread VM. + CACHED_GLOBAL_OBJECT.set(Some(unsafe { (*vm).global })); + } #[inline(always)] fn set_cached_global_object(g: Option<*mut JSGlobalObject>) { CACHED_GLOBAL_OBJECT.set(g) @@ -704,6 +710,14 @@ unsafe impl Sync for VirtualMachine {} unsafe impl Send for VirtualMachine {} impl VirtualMachine { + /// Snapshot: the main-thread VM recorded in a plain static (survives a snapshot restore, unlike TLS). + pub fn main_thread_vm_ptr() -> *mut VirtualMachine { + MAIN_THREAD_VM.load(core::sync::atomic::Ordering::Acquire) + } + pub fn adopt_on_current_thread(vm: *mut VirtualMachine) { + VMHolder::adopt(vm); + } + /// Safe `&'static` accessor for the current thread's VM. The VM is a /// per-thread singleton allocated once in [`init`] and never freed until /// thread teardown, so the `'static` lifetime is sound. Mutation goes @@ -1189,6 +1203,7 @@ impl VirtualMachine { self.is_event_loop_alive_excluding_immediates() || !el.immediate_tasks.is_empty() || !el.next_immediate_tasks.is_empty() + || (self.is_main_thread() && bun_core::startup_snapshot::snapshot_requested()) // keep turning until the outermost tick takes the snapshot } pub fn wakeup(&mut self) { @@ -2076,6 +2091,8 @@ pub struct RuntimeHooks { /// (forward-dep cycle), so [`uncaught_exception`] reaches it through this /// slot instead of the linker. pub process_exit: unsafe fn(global: *mut JSGlobalObject, code: u8), + /// A snapshot was requested and the resulting termination reached the top of the event loop: quiesce and write the snapshot (noreturn). + pub take_snapshot: fn(vm: *mut VirtualMachine) -> !, /// `onBeforePrint()` for the `bun:test` runner, which lives in `bun_runtime`; /// `console.log` calls this so the test reporter can flush its line state /// before user output interleaves with it. No-op when `bun test` isn't @@ -2326,7 +2343,7 @@ unsafe extern "C" { ) -> *mut JSInternalPromise; } -fn get_origin_timestamp() -> u64 { +pub fn get_origin_timestamp() -> u64 { // Subtract the Y2K epoch so the timestamp fits in a u64 (nanoseconds). let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -3251,6 +3268,14 @@ fn ensure_source_code_printer() { } } +/// Made on first use: thread-locals are not in a snapshot, so a restored process arrives here with the slot empty. +pub(crate) fn source_code_printer() -> NonNull { + ensure_source_code_printer(); + SOURCE_CODE_PRINTER + .get() + .expect("ensure_source_code_printer just set it") +} + /// Free this thread's [`SOURCE_CODE_PRINTER`] Box (if any). fn drop_source_code_printer() { if let Some(printer) = SOURCE_CODE_PRINTER.take() { @@ -3479,29 +3504,17 @@ impl VirtualMachine { self.rare_data().mime_type_from_string(str_) } - /// Applies env-derived runtime settings, claims the per-thread source code printer, and adopts `NODE_CHANNEL_FD` for IPC. - pub fn load_extra_env_and_source_code_printer(&mut self) { - // `Transpiler::env_mut()` encapsulates the raw-ptr deref; the returned - // `&'static mut Loader` is independent of `&self`, so `map` may be held - // across the `&mut self` writes below. + /// Snapshot restore: defaults latched from the builder's environment must not survive; TLS verification in particular must not stay off. + pub fn forget_env_derived_defaults_for_snapshot_restore(&mut self) { + self.default_tls_reject_unauthorized = None; + self.default_verbose_fetch.set(None); + } + + /// The channel belongs to the launch: run at boot, and again once a snapshot restore has reloaded the environment. + pub fn adopt_ipc_channel_from_env(&mut self) { + self.pending_ipc = None; // a builder that was itself spawned with a channel left one here let env = self.transpiler.env_mut(); let map = &mut env.map; - - ensure_source_code_printer(); - // The runtime VM owns the printer from here on — even if a macro had - // allocated it first, `__bun_macro_context_deinit` must not free it. - SOURCE_CODE_PRINTER_FROM_MACRO.set(false); - - if map.get(b"BUN_SHOW_BUN_STACKFRAMES").is_some() { - self.hide_bun_stackframes = false; - } - - if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER::get() - .unwrap_or(false) - { - self.transpiler_store.enabled = false; - } - if let Some(idx) = map.map.get_index(b"NODE_CHANNEL_FD") { let (_, kv) = map.map.swap_remove_at(idx); let fd_s = kv.value; @@ -3535,6 +3548,30 @@ impl VirtualMachine { } } } + } + + /// Applies env-derived runtime settings, claims the per-thread source code printer, and adopts `NODE_CHANNEL_FD` for IPC. + pub fn load_extra_env_and_source_code_printer(&mut self) { + // `Transpiler::env_mut()` encapsulates the raw-ptr deref; the returned + // `&'static mut Loader` is independent of `&self`, so `map` may be held + // across the `&mut self` writes below. + let env = self.transpiler.env_mut(); + let map = &mut env.map; + + ensure_source_code_printer(); + // The runtime VM owns the printer from here on — even if a macro had + // allocated it first, `__bun_macro_context_deinit` must not free it. + SOURCE_CODE_PRINTER_FROM_MACRO.set(false); + + if map.get(b"BUN_SHOW_BUN_STACKFRAMES").is_some() { + self.hide_bun_stackframes = false; + } + + if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER::get() + .unwrap_or(false) + { + self.transpiler_store.enabled = false; + } // Node.js checks if this is set to "1" and no other value if let Some(value) = map.get(b"NODE_PRESERVE_SYMLINKS") { @@ -3575,6 +3612,8 @@ impl VirtualMachine { } } } + + self.adopt_ipc_channel_from_env(); // last: it takes its own borrow of the env map, so nothing above may still hold one } /// Routes an unhandled promise rejection to the configured handler, bumping the unhandled-error counter. @@ -4228,9 +4267,7 @@ impl VirtualMachine { } let mut guard = ArenaReset(jsc_vm, flags != FetchFlags::PrintSource); - let printer = SOURCE_CODE_PRINTER - .get() - .expect("source_code_printer not initialized"); + let printer = source_code_printer(); // Note: the §Dispatch shim takes path/loader/module_type/printer/ // promise_ptr bundled as `TranspileExtra` behind `args.extra` (see diff --git a/src/jsc/VmHandle.rs b/src/jsc/VmHandle.rs index 1a536254a518..b2eed0eb10a0 100644 --- a/src/jsc/VmHandle.rs +++ b/src/jsc/VmHandle.rs @@ -94,7 +94,7 @@ pub struct Shared { /// [`VmHandle::embedded_work_scheduled`]); teardown waits for zero. embedded: AtomicU32, #[cfg(debug_assertions)] - js_thread: std::thread::ThreadId, + js_thread: std::sync::Mutex, /// Test suite only — see [`refusal_gate`]. #[cfg(debug_assertions)] park_posts: core::sync::atomic::AtomicBool, @@ -149,7 +149,7 @@ impl VmHandle { drained: (Mutex::new(), Condvar::new()), embedded: AtomicU32::new(0), #[cfg(debug_assertions)] - js_thread: std::thread::current().id(), + js_thread: std::sync::Mutex::new(std::thread::current().id()), #[cfg(debug_assertions)] park_posts: core::sync::atomic::AtomicBool::new(false), })) @@ -329,12 +329,24 @@ impl VmHandle { #[cfg(debug_assertions)] pub(crate) fn assert_js_thread(&self) { - debug_assert_eq!(std::thread::current().id(), self.0.js_thread); + debug_assert_eq!( + std::thread::current().id(), + *self.0.js_thread.lock().unwrap() + ); } #[cfg(not(debug_assertions))] #[inline(always)] pub(crate) fn assert_js_thread(&self) {} + /// Snapshot restore: the JS thread is now the calling thread, not the builder's. + #[cfg(debug_assertions)] + pub fn readopt_js_thread(&self) { + *self.0.js_thread.lock().unwrap() = std::thread::current().id(); + } + #[cfg(not(debug_assertions))] + #[inline(always)] + pub fn readopt_js_thread(&self) {} + /// The VM is going away: `Open → Stopping` (idempotent; never reopens or /// un-closes). Any thread — a parent's `terminate()` calls it at request /// time, as Node's `Environment::ExitEnv` sets `is_stopping` from the @@ -403,7 +415,7 @@ mod refusal_gate { pub(super) fn before_post(h: &VmHandle) { if !h.posts_parked() - || std::thread::current().id() == h.0.js_thread + || std::thread::current().id() == *h.0.js_thread.lock().unwrap() || h.0.embedded.load(Ordering::SeqCst) != 0 { return; diff --git a/src/jsc/bindings/BunJSCEventLoop.cpp b/src/jsc/bindings/BunJSCEventLoop.cpp index 736fdd6207fb..f9fa4139f2d4 100644 --- a/src/jsc/bindings/BunJSCEventLoop.cpp +++ b/src/jsc/bindings/BunJSCEventLoop.cpp @@ -23,9 +23,13 @@ extern "C" uint64_t us_internal_monotonic_ns(void); // it as an atomic rather than through a plain `int`. extern "C" std::atomic Bun__defaultRemainingRunsUntilSkipReleaseAccess; +#include "StartupSnapshot.h" extern "C" void Bun__JSC_onBeforeWait(JSC::VM* _Nonnull vm, uint64_t nowNs) { ASSERT(vm); +#if BUN_STARTUP_SNAPSHOT_TOOLING + Bun__startupSnapshotToolingTick(vm); +#endif const bool previouslyHadAccess = vm->heap.hasHeapAccess(); // sanity check for debug builds to ensure we're not doing a // use-after-free here diff --git a/src/jsc/bindings/BunObject+exports.h b/src/jsc/bindings/BunObject+exports.h index 31319ae85641..51916e49fb7b 100644 --- a/src/jsc/bindings/BunObject+exports.h +++ b/src/jsc/bindings/BunObject+exports.h @@ -40,6 +40,7 @@ macro(origin) \ macro(s3) \ macro(semver) \ + macro(startupSnapshot) \ macro(unsafe) \ macro(valkey) \ diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 8a022228eb4c..bf11237526e4 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -1006,6 +1006,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj resolveSync BunObject_callback_resolveSync DontDelete|Function 1 revision constructBunRevision ReadOnly|DontDelete|PropertyCallback semver BunObject_lazyPropCb_wrap_semver ReadOnly|DontDelete|PropertyCallback + startupSnapshot BunObject_lazyPropCb_wrap_startupSnapshot ReadOnly|DontDelete|PropertyCallback sql defaultBunSQLObject DontDelete|PropertyCallback postgres defaultBunSQLObject DontDelete|PropertyCallback SQL constructBunSQLObject DontDelete|PropertyCallback @@ -1201,3 +1202,44 @@ void generateNativeModule_BunObject(JSC::JSGlobalObject* lexicalGlobalObject, } } // namespace Zig + +// snapshot restore: launch-derived lazy properties that were already reified into own properties on the Bun object +// get their value recomputed for this process (same callbacks as first access). +extern "C" void Bun__BunObject__refreshLaunchDerivedProperties(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + globalObject->armStdioBlobs(); // whether or not Bun itself was reified: the slots behind Bun.stdin/stdout/stderr hold the builder's blobs + if (!globalObject->m_bunObject.isInitialized()) + return; // never touched during the build: nothing was reified, and making it now would only dirty pages + JSObject* bunObject = globalObject->bunObject(); + struct LaunchDerivedProp { + ASCIILiteral name; + JSValue (*make)(VM&, JSObject*); + }; + static const LaunchDerivedProp props[] = { + { "argv"_s, BunObject_lazyPropCb_wrap_argv }, + { "cwd"_s, BunObject_lazyPropCb_wrap_cwd }, + { "enableANSIColors"_s, BunObject_lazyPropCb_wrap_enableANSIColors }, + // importing anything from "bun" reifies every entry, so these are present in practically every snapshot + { "s3"_s, BunObject_lazyPropCb_wrap_s3 }, + { "stdin"_s, Bun::BunObject_lazyPropCb_wrap_stdin }, + { "stdout"_s, Bun::BunObject_lazyPropCb_wrap_stdout }, + { "stderr"_s, Bun::BunObject_lazyPropCb_wrap_stderr }, + { "redis"_s, BunObject_lazyPropCb_wrap_valkey }, // the default client is built from REDIS_URL, credentials included + }; + for (auto& p : props) { + JSC::Identifier id = JSC::Identifier::fromString(vm, p.name); + if (bunObject->getDirectOffset(vm, id) == invalidOffset) + continue; // never accessed: the static-table callback will run on first access + JSValue fresh = p.make(vm, bunObject); + if (scope.exception() || !fresh) { // e.g. this launch's REDIS_URL is malformed: say so and leave nothing of the builder's behind; 'restore' must still fire + auto* exception = scope.exception(); + (void)scope.tryClearException(); // before stringifying: toString bails while an exception is pending + WTF::String why = exception ? exception->value().toWTFStringForConsole(globalObject) : "no value"_s; + fprintf(stderr, "[snapshot] Bun.%s could not be remade for this launch: %s\n", p.name.characters(), why.utf8().data()); + fresh = JSC::jsUndefined(); + } + bunObject->putDirect(vm, id, fresh, 0); + } +} diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index cac125f29df3..45798fe7c88a 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -1570,6 +1570,18 @@ extern "C" void Bun__installWatchModeSignalHandler(int signalNumber) } #endif +// Signal dispositions are kernel state: a resumed process has the listener table but none of the handlers the build installed. +extern "C" void Bun__Process__reinstallSignalHandlersAfterSnapshotRestore() +{ +#if !OS(WINDOWS) + if (!signalToContextIdsMap || signalToContextIdsMap->isEmpty()) + return; + Bun__ensureSignalHandler(); + for (auto& entry : *signalToContextIdsMap) + installForwardSignalHandler(entry.key); +#endif +} + extern "C" void Bun__MemoryPressure__install(JSC::JSGlobalObject* global); extern "C" void Bun__MemoryPressure__uninstall(JSC::JSGlobalObject* global); @@ -2953,13 +2965,25 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return resultObject->getIndex(globalObject, 0); } +extern "C" bool Bun__startupSnapshotIsBuilding(); +extern "C" void Bun__startupSnapshotNoteStdioStream(int fd, const uint8_t* site, size_t len); +static void noteStdioStreamForSnapshotBuild(JSObject* processObject, int fd) +{ + if (!Bun__startupSnapshotIsBuilding()) [[likely]] + return; + auto site = Bun::snapshotReportCallSite(processObject->globalObject()).utf8(); + Bun__startupSnapshotNoteStdioStream(fd, reinterpret_cast(site.data()), site.length()); +} + static JSValue constructStdout(VM& vm, JSObject* processObject) { + noteStdioStreamForSnapshotBuild(processObject, 1); return constructStdioWriteStream(processObject->globalObject(), processObject, 1); } static JSValue constructStderr(VM& vm, JSObject* processObject) { + noteStdioStreamForSnapshotBuild(processObject, 2); return constructStdioWriteStream(processObject->globalObject(), processObject, 2); } @@ -2969,6 +2993,7 @@ static JSValue constructStderr(VM& vm, JSObject* processObject) static JSValue constructStdin(VM& vm, JSObject* processObject) { + noteStdioStreamForSnapshotBuild(processObject, 0); auto* globalObject = processObject->globalObject(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSC::JSFunction* getStdinStream = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetStdinStreamCodeGenerator(vm), globalObject); @@ -3078,6 +3103,8 @@ static JSValue constructExecPath(VM& vm, JSObject* processObject) return JSValue::decode(Bun__Process__getExecPath(globalObject)); } +static void refreshReifiedLaunchProperties(VM&, JSObject*); + extern "C" EncodedJSValue Bun__Process__getArgv(JSGlobalObject* lexicalGlobalObject) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -3215,6 +3242,110 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) return JSC::jsString(vm, makeAtomString(ASCIILiteral::fromLiteralUnsafe(Bun__version_sha))); } +// snapshot restore: process.env holds the builder's environment; rebuild it from the reloaded loader map. +extern "C" void Bun__BunObject__refreshLaunchDerivedProperties(Zig::GlobalObject*); +// Streams created before the snapshot describe the build's descriptors: terminal-to-terminal keeps the object (captured references stay valid) and refreshes its size; anything else is rebuilt for this launch. +extern "C" void Bun__Process__recreateStdioAfterSnapshotRestore(JSC::JSGlobalObject* lexicalGlobalObject) +{ + auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + JSC::JSLockHolder lock(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* process = globalObject->processObject(); + struct { + ASCIILiteral name; + int fd; + JSValue (*construct)(VM&, JSObject*); + } streams[] = { + { "stdin"_s, 0, constructStdin }, + { "stdout"_s, 1, constructStdout }, + { "stderr"_s, 2, constructStderr }, + }; + for (auto& stream : streams) { + auto ident = Identifier::fromString(vm, stream.name); + JSValue existing = process->getDirect(vm, ident); + if (existing.isEmpty()) + continue; // never created: the lazy property will build one for this launch on first use + bool wasTerminal = false; + if (auto* object = existing.getObject()) { + wasTerminal = object->get(globalObject, Identifier::fromString(vm, "isTTY"_s)).isTrue(); + if (scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + wasTerminal = false; + } + } + // A stream whose fd is a terminal again is kept: the app's listeners stay attached, its poll was re-armed onto the + // re-seated fd; the size is the new terminal's. + if (wasTerminal && bun_stdio_tty[stream.fd]) { + auto* object = existing.getObject(); +#if !OS(WINDOWS) + struct winsize size; + if (ioctl(stream.fd, TIOCGWINSZ, &size) == 0) { + object->putDirect(vm, Identifier::fromString(vm, "columns"_s), jsNumber(size.ws_col)); + object->putDirect(vm, Identifier::fromString(vm, "rows"_s), jsNumber(size.ws_row)); + } +#endif + continue; + } + process->putDirect(vm, ident, stream.construct(vm, process)); + } +} + +extern "C" void Bun__Process__reloadEnvAfterSnapshotRestore(JSC::JSGlobalObject* lexicalGlobalObject) +{ + auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + JSC::JSLockHolder lock(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSValue fresh = Bun::createEnvironmentVariablesMap(globalObject); + if (scope.exception() || !fresh || !fresh.isObject()) { + (void)scope.tryClearException(); + return; + } + // Snapshots built by `bun` use a store-backed process.env whose contents change under every reference; only other builds need the object replaced. + JSObject* process = globalObject->processObject(); + if (!Bun::refillSharedEnvAfterSnapshotRestore(globalObject, fresh.getObject())) { + globalObject->m_processEnvObject.set(vm, globalObject, fresh.getObject()); + process->putDirect(vm, JSC::Identifier::fromString(vm, "env"_s), fresh, 0); + } + uncheckedDowncast(process)->invalidateLaunchContext(); + refreshReifiedLaunchProperties(vm, process); + (void)scope.tryClearException(); +} + +// PropertyCallback entries are reified into own properties on first read; a builder that read them left the building +// process's values on the object. Same shape as Bun__BunObject__refreshLaunchDerivedProperties. +static void refreshReifiedLaunchProperties(VM& vm, JSObject* processObject) +{ + struct LaunchDerivedProp { + ASCIILiteral name; + JSValue (*make)(VM&, JSObject*); + }; + static const LaunchDerivedProp props[] = { + { "pid"_s, constructPid }, + { "argv0"_s, constructArgv0 }, + { "execPath"_s, constructExecPath }, + // undefined in the builder, which has no channel; `if (process.send)` at module scope reifies that during the build + { "send"_s, constructProcessSend }, + { "disconnect"_s, constructProcessDisconnect }, + { "channel"_s, constructProcessChannel }, + }; + for (auto& p : props) { + JSC::Identifier id = JSC::Identifier::fromString(vm, p.name); + if (processObject->getDirectOffset(vm, id) == invalidOffset) + continue; // never read during the build: the static-table callback runs on first access + processObject->putDirect(vm, id, p.make(vm, processObject), 0); + } +} + +void Process::invalidateLaunchContext() +{ + // Lazily materialized from argv / cwd / exec path / title of the launching process; the getters rebuild them. + m_argv.clear(); + m_execArgv.clear(); + m_cachedCwd.clear(); +} + static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index ee606dce1ce3..76db456a91be 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -88,6 +88,8 @@ class Process : public WebCore::JSEventEmitter { JSString* cachedCwd() { return m_cachedCwd.get(); } void setCachedCwd(JSC::VM& vm, JSString* cwd) { m_cachedCwd.set(vm, this, cwd); } void clearCachedCwd() { m_cachedCwd.clear(); } + // snapshot restore: drop every JS value materialized from the launch context of the process that built the snapshot. + void invalidateLaunchContext(); JSValue getArgv(JSGlobalObject* globalObject); void setArgv(JSGlobalObject* globalObject, JSValue argv); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 108048b74051..53acff280478 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -1,8 +1,11 @@ #include "root.h" +#include +#include #include "ZigGlobalObject.h" #include "helpers.h" #include "JSEnvironmentVariableMap.h" +#include "FormatStackTraceForJS.h" #include #include @@ -632,6 +635,8 @@ bool JSSharedEnvMap::getOwnPropertySlot(JSObject* object, JSGlobalObject* global } auto* store = sharedEnvStoreFor(object); + if (store && store->isRecordingReads()) [[unlikely]] + store->noteRead(String(uid)); String value = store ? store->get(String(uid)) : String(); if (value.isNull()) { return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); @@ -673,6 +678,16 @@ static void applyTZFromString(JSGlobalObject* globalObject, const String& value) if (value.length() < 32 && WTF::setTimeZoneOverride(value)) resetDateCachesAfterTimeZoneChange(JSC::getVM(globalObject)); } +// Snapshot restore: the override static and the VM's date cache both hold the building process's zone; this launch's TZ (or its +// absence, i.e. the system zone of this machine) applies instead. The caches are reset either way, since the cached zone is stale either way. +extern "C" void Bun__refreshTimeZoneAfterSnapshotRestore(JSGlobalObject* globalObject, const char* tz, size_t tzLen) +{ + WTF::setTimeZoneOverride(String()); // first: a zone ICU rejects must leave the system zone in effect, as at boot, not the builder's override + if (tzLen > 0) // no length cap: boot (JSGlobalObject__setTimeZone) has none either + WTF::setTimeZoneOverride(String::fromUTF8(std::span { tz, tzLen })); + resetDateCachesAfterTimeZoneChange(JSC::getVM(globalObject)); +} + static void applyTLSRejectFromString(JSGlobalObject*, const String& value) { /* Node only treats the exact string "0" as disabling verification. */ @@ -783,10 +798,35 @@ bool JSSharedEnvMap::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, return Base::deleteProperty(cell, globalObject, propertyName, slot); } +// The innermost few JS frames, formatted like an error stack (source maps applied), as the key a copy of process.env is reported under. +String snapshotReportCallSite(JSGlobalObject* lexicalGlobalObject) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + VM& vm = JSC::getVM(globalObject); + WTF::Vector frames; + vm.interpreter.getStackTrace(globalObject, frames, 0, 4); + OrdinalNumber line = OrdinalNumber::beforeFirst(); + OrdinalNumber column = OrdinalNumber::beforeFirst(); + String sourceURL; + String formatted = Bun::formatStackTrace(vm, globalObject, lexicalGlobalObject, "Error"_s, String(), line, column, sourceURL, frames, nullptr); + StringBuilder site; + bool first = true; + for (auto frameLine : StringView(formatted).split('\n')) { + if (first) { // the "Error" header line + first = false; + continue; + } + site.append(site.isEmpty() ? ""_s : "\n"_s, " "_s, frameLine.trim(isASCIIWhitespace)); + } + return site.toString(); +} + void JSSharedEnvMap::getOwnPropertyNames(JSObject* object, JSGlobalObject* globalObject, PropertyNameArrayBuilder& propertyNames, DontEnumPropertiesMode mode) { VM& vm = JSC::getVM(globalObject); if (auto* store = sharedEnvStoreFor(object)) { + if (store->isRecordingReads()) [[unlikely]] + store->noteEnumeration(snapshotReportCallSite(globalObject)); for (const auto& key : store->keys()) propertyNames.add(JSC::Identifier::fromString(vm, key)); } @@ -951,6 +991,80 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } +// Snapshot build: from here on `process.env` is a view over a store, so a restored process can swap the contents +// underneath every reference the app captured, and reads before the freeze can be reported when the snapshot is written. +extern "C" void Bun__Process__useSharedEnvForSnapshotBuild(JSC::JSGlobalObject* lexicalGlobalObject) +{ + auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + JSC::JSLockHolder lock(globalObject->vm()); + if (RefPtr store = ensureSharedEnvStoreForWorker(globalObject)) + store->startRecordingReads(); +} + +// Restore: refill the store from this process's environment (the loader has already been reloaded). Returns false when +// process.env is not store-backed (a snapshot built without the step above), and the caller replaces the object instead. +bool refillSharedEnvAfterSnapshotRestore(Zig::GlobalObject* globalObject, JSC::JSObject* freshEnvObject) +{ + auto* store = sharedEnvStoreFor(globalObject); + if (!store) + return false; + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + freshEnvObject->methodTable()->getOwnPropertyNames(freshEnvObject, globalObject, keys, JSC::DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + Vector> entries; + entries.reserveInitialCapacity(keys.size()); + for (const auto& key : keys) { + JSValue value = freshEnvObject->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + if (value.isCallable()) + continue; + String str = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + entries.append({ String(key.impl()), WTF::move(str) }); + } + store->replaceAll(WTF::move(entries)); + return true; +} + +// Printed by the snapshot writer. `excluded` are the envGate names: those are handled by construction. +void printEnvReadsBeforeSnapshot(Zig::GlobalObject* globalObject, const Vector& excluded) +{ + auto* store = sharedEnvStoreFor(globalObject); + if (!store) + return; + Vector names; + for (auto& name : store->readKeys()) { + if (!excluded.contains(name)) + names.append(name); + } + std::sort(names.begin(), names.end(), WTF::codePointCompareLessThan); + unsigned enumerations = store->enumerations(); + auto sites = store->enumerationSites(); + store->finishRecordingReads(); + if (names.isEmpty() && !enumerations) + return; + StringBuilder out; + out.append("snapshot: values read from process.env before the freeze are baked into the snapshot; read them in a 'restore' listener or list them in envGate:"_s); + if (enumerations) { + out.append("\n process.env was enumerated or copied "_s, enumerations, enumerations == 1 ? " time"_s : " times"_s, " (every variable)"_s); + std::sort(sites.begin(), sites.end(), [](auto& a, auto& b) { return a.second > b.second; }); + for (auto& [site, count] : sites) { + out.append("\n "_s, count, count == 1 ? " copy from:\n"_s : " copies from:\n"_s, site); + } + } + // A copy reads every variable on the way through; listing them individually would say nothing more. + if (!names.isEmpty() && (!enumerations || names.size() < store->keys().size())) { + out.append("\n "_s); + for (size_t i = 0; i < names.size(); i++) + out.append(i ? ", "_s : ""_s, names[i]); + } + out.append('\n'); + auto utf8 = out.toString().utf8(); + fwrite(utf8.data(), 1, utf8.length(), stderr); +} + JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 90d0f055e7ee..b6c62b81bc7b 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -1,3 +1,4 @@ +#pragma once #include "root.h" #include "SharedEnvStore.h" @@ -77,5 +78,13 @@ bool isProcessEnvClassInfo(const JSC::ClassInfo*); // else a fresh one seeded from its `process.env` (then swapped to a write-through view). // Returns null if seeding threw. RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalObject); +// Snapshots (see the definitions): refill the store-backed process.env in place after a restore; report build-time reads. +bool refillSharedEnvAfterSnapshotRestore(Zig::GlobalObject*, JSC::JSObject* freshEnvObject); +void printEnvReadsBeforeSnapshot(Zig::GlobalObject*, const Vector& excludedNames); } + +namespace Bun { +// The JS frames (up to 4) currently on the stack, one per line, for the reports the snapshot build prints. +WTF::String snapshotReportCallSite(JSC::JSGlobalObject*); +} diff --git a/src/jsc/bindings/SharedEnvStore.h b/src/jsc/bindings/SharedEnvStore.h index 752282f953ca..ca80ab45d84d 100644 --- a/src/jsc/bindings/SharedEnvStore.h +++ b/src/jsc/bindings/SharedEnvStore.h @@ -2,6 +2,8 @@ #include "root.h" #include +#include +#include #include #include #include @@ -62,6 +64,58 @@ class SharedEnvStore : public ThreadSafeRefCounted { return out; } + // Snapshot restore: the environment is the launching process's now; every view of this store sees it at once. + void replaceAll(Vector>&& entries) + { + { + Locker locker { m_lock }; + m_map.clear(); + } + for (auto& [key, value] : entries) + set(key, value); + } + + // While a snapshot is being built, remember what the app read: values read before the freeze are baked into the snapshot. + void startRecordingReads() { m_recordReads = true; } + void finishRecordingReads() // called once the report is printed, before the freeze: the snapshot bakes in "not recording" + { + m_recordReads = false; + Locker locker { m_lock }; + m_readKeys = {}; + m_enumerationSites = {}; + } + bool isRecordingReads() const { return m_recordReads; } + void noteRead(const String& key) + { + Locker locker { m_lock }; + m_readKeys.add(key.isolatedCopy()); + } + void noteEnumeration(const String& site) + { + Locker locker { m_lock }; + m_enumerations++; + m_enumerationSites.add(site.isolatedCopy(), 0).iterator->value++; + } + Vector readKeys() + { + Locker locker { m_lock }; + return copyToVector(m_readKeys); + } + unsigned enumerations() + { + Locker locker { m_lock }; + return m_enumerations; + } + Vector> enumerationSites() + { + Locker locker { m_lock }; + Vector> out; + out.reserveInitialCapacity(m_enumerationSites.size()); + for (auto& entry : m_enumerationSites) + out.append({ entry.key.isolatedCopy(), entry.value }); + return out; + } + // Windows env keys are case-insensitive. This follows bun's own Windows env // object, not node: node only folds case for a main-rooted tree (RealEnvStore), // and is case-sensitive for one rooted at a snapshot worker (MapKVStore). @@ -90,6 +144,10 @@ class SharedEnvStore : public ThreadSafeRefCounted { Lock m_lock; HashMap m_map WTF_GUARDED_BY_LOCK(m_lock); + bool m_recordReads { false }; + unsigned m_enumerations WTF_GUARDED_BY_LOCK(m_lock) { 0 }; + HashSet m_readKeys WTF_GUARDED_BY_LOCK(m_lock); + HashMap m_enumerationSites WTF_GUARDED_BY_LOCK(m_lock); }; } // namespace Bun diff --git a/src/jsc/bindings/StartupSnapshot.cpp b/src/jsc/bindings/StartupSnapshot.cpp new file mode 100644 index 000000000000..738c7b9c2919 --- /dev/null +++ b/src/jsc/bindings/StartupSnapshot.cpp @@ -0,0 +1,2003 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE 1 // dl_iterate_phdr / dl_phdr_info (Linux) +#endif +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wformat" // uint64_t is unsigned long on Linux, unsigned long long on Darwin; this file prints a lot of addresses +#include "root.h" +#include "StartupSnapshot.h" +#include "JSEnvironmentVariableMap.h" +// Supported platforms build the real thing; elsewhere the same entry points exist (so everything links) and report the feature as absent. +#if BUN_STARTUP_SNAPSHOT_SUPPORTED +#include +#if OS(DARWIN) +#include +#endif +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if OS(DARWIN) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif +#if OS(LINUX) +#include +#include +#include +#include +#include +#include +#endif +#ifndef MAP_JIT +#define MAP_JIT 0 +#endif +#include +#include +#include +#include "wtf/SIMDUTF.h" +#include +#include +#include +#include +#include +#include + +extern "C" int mi_prof_dump_to_file(const char*) noexcept; +extern "C" void mi_prof_enable(size_t) noexcept; +typedef void(mi_output_fun)(const char* msg, void* arg); +extern "C" void mi_stats_print_out(mi_output_fun* out, void* arg) noexcept; +extern "C" void mi_arenas_print(void) noexcept; +extern "C" void mi_collect(bool force) noexcept; +extern "C" size_t mi_usable_size(const void*) noexcept; +extern "C" int mi_heap_snapshot_to_file(const char* path, unsigned flags) noexcept; +extern "C" void mi_arenas_freeze_pages() noexcept; +extern "C" void mi_prof_visit_live(bool (*cb)(uintptr_t addr, size_t size, const uintptr_t* frames, uint8_t nframes, void* arg), void* arg) noexcept; +#include +#include "ZigGlobalObject.h" +namespace Bun::StartupSnapshot { +std::vector> frozenRanges; // sorted [start,end) +std::vector snapshotRuns; +int snapshotFd = -1; +::mi_heap_s* freshHeap = nullptr; +off_t snapshotBaseOff = 0; +ssize_t ipread(int fd, void* buf, size_t n, off_t off) { return ::pread(fd, buf, n, off + snapshotBaseOff); } +void* immap(void* addr, size_t len, int prot, int flags, int fd, off_t off) { return ::mmap(addr, len, prot, flags, fd, off + snapshotBaseOff); } +} // namespace Bun::StartupSnapshot +using namespace Bun::StartupSnapshot; +#if OS(DARWIN) +#define OS_DARWIN_ONLY(x) x +#else +#define OS_DARWIN_ONLY(x) 0 +#endif +// Snapshot bytes may live at an offset inside a bigger file (embedded in the executable's __BUN/.bun section): all snapshot-file reads/maps add this. + +static void snapshotRestoreAndRun(const char* path); +extern "C" struct mach_header_64 _mh_execute_header; +// A snapshot needs the executable at its link address: if dyld slid us, re-exec ourselves unslid (macOS private posix_spawn flag), carrying the allocator/JIT settings in the env. +extern "C" void bun_refresh_stdio_after_snapshot_restore(); +extern "C" volatile sig_atomic_t bun_stdio_modified[3]; +extern "C" int bun_is_compiled_executable(void); +extern "C" bool Bun__isCompiledExecutable() { return bun_is_compiled_executable(); } +extern "C" bool Bun__startupSnapshotMode() { return bun_is_compiled_executable() || getenv("BUN_STARTUP_SNAPSHOT_IN") || getenv("BUN_STARTUP_SNAPSHOT_OUT"); } +// Restore epoch (0 = booted normally or building; N after the Nth restore): statics caching process/OS/CPU state key their once-token on `epoch + 1` instead of a bool. +extern "C" uint32_t bun_snapshot_epoch; // defined (exported, unmangled) in bun_core::startup_snapshot; std::atomic layout == uint32_t + +namespace bssl { +void OPENSSL_cpuid_setup(); +} +// CPU-dispatch latches in vendored code chose paths on the build machine (valid here per the header's feature-superset check); re-probing lets a better CPU do better. +static void snapshotReprobeCPUDispatch() +{ + hwy::GetChosenTarget().DeInit(); // next HWY_DYNAMIC_DISPATCH re-detects + simdutf::get_active_implementation() = simdutf::get_available_implementations().detect_best_supported(); + bssl::OPENSSL_cpuid_setup(); // refills OPENSSL_ia32cap_P / OPENSSL_armcap_P +} + +static bool s_snapshotActive = false; // set once this process is building a snapshot or has restored one (decided in Bun__startupSnapshotMaybeRestore, before VM init) +// A launch that resumes from its snapshot, or declines it and boots normally, says nothing unless asked (BUN_STARTUP_SNAPSHOT_VERBOSE=1). +static bool snapshotVerbose() +{ + return !!getenv("BUN_STARTUP_SNAPSHOT_VERBOSE"); // not cached: a value cached while the snapshot was written would be restored along with it +} +extern "C" bool Bun__startupSnapshotActive() { return s_snapshotActive; } +#if OS(DARWIN) && defined(BUN_MIMALLOC_ZONE_OVERRIDE) +extern "C" size_t mi_malloc_zone_process_owned_ranges(uintptr_t (*out)[2], size_t cap); +#endif +// On macOS snapshots need mimalloc registered as the process malloc zone (BUN_MIMALLOC_OVERRIDE_DARWIN at build time), which official builds do not enable yet. +extern "C" bool Bun__startupSnapshotSupported() +{ +#if OS(DARWIN) && !defined(BUN_MIMALLOC_ZONE_OVERRIDE) + return false; +#else + return true; +#endif +} + +// BUN_STARTUP_SNAPSHOT_VERBOSE timing: milliseconds since the process was exec'd (Darwin: the kernel's start time, which the re-exec keeps). +static double snapshotMsSinceExec() +{ + struct timeval now; + gettimeofday(&now, nullptr); +#if OS(DARWIN) + struct proc_bsdinfo info; + if (proc_pidinfo(getpid(), PROC_PIDTBSDINFO, 0, &info, sizeof info) == sizeof info) + return (now.tv_sec - (double)info.pbi_start_tvsec) * 1000.0 + (now.tv_usec - (double)info.pbi_start_tvusec) / 1000.0; +#endif + static struct timeval first = now; + return (now.tv_sec - first.tv_sec) * 1000.0 + (now.tv_usec - first.tv_usec) / 1000.0; +} +static void snapshotTimingMark(const char* what) +{ + if (snapshotVerbose()) + fprintf(stderr, "[snapshot] t=%.2fms since exec: %s\n", snapshotMsSinceExec(), what); +} + +extern "C" bool Bun__isCompiledExecutable(); +static void setSnapshotEnvDefaults() +{ + bool building = getenv("BUN_STARTUP_SNAPSHOT_OUT"); + if (Bun__isCompiledExecutable() && !building) + return; // compiled executables configure the allocator/JIT from BUN_COMPILED before main: nothing to pass through the environment + setenv("MIMALLOC_DETERMINISTIC_HINT", "1", 0); + // The builder's heap (= the snapshot) starts at mimalloc's 2TiB base; a restoring process puts its own early heap 64GiB higher so it never occupies snapshot addresses. + if (getenv("BUN_STARTUP_SNAPSHOT_OUT")) + unsetenv("MIMALLOC_HINT_FLOOR"); + else + setenv("MIMALLOC_HINT_FLOOR", "0x21000000000", 0); + setenv("BUN_STARTUP_SNAPSHOT_JIT_ADDR", "0x3c0000000", 0); +} +static bool snapshotEnvIsSet() +{ + bool building = getenv("BUN_STARTUP_SNAPSHOT_OUT"); + if (Bun__isCompiledExecutable() && !building) return true; // compiled executables configure allocator/JIT from BUN_COMPILED before main: nothing to inject + return getenv("MIMALLOC_DETERMINISTIC_HINT") && getenv("BUN_STARTUP_SNAPSHOT_JIT_ADDR") && (building || getenv("MIMALLOC_HINT_FLOOR")); +} + +static void reexecWithoutASLRIfSlid() +{ + // The re-exec'd generation is tagged in argv[0] so it never re-execs again even if disabling ASLR silently failed; the tag is + // cut off again right here, before anything (Bun's argv capture, ps) can see it. + static constexpr const char* kReexecTag = " [snapshot-reexec]"; +#if OS(DARWIN) + bool alreadyReexeced = false; + if (char* argv0 = (*_NSGetArgv())[0]) { + if (char* tag = strstr(argv0, kReexecTag)) { + *tag = '\0'; + alreadyReexeced = true; + } + } +#else + const bool alreadyReexeced = false; +#endif + if (getenv("BUN_STARTUP_SNAPSHOT_REEXECED") || alreadyReexeced) + return; + bool needEnv = !snapshotEnvIsSet(); +#if OS(DARWIN) + constexpr uintptr_t linkBase = 0x100000000ull; + if ((uintptr_t)&_mh_execute_header == linkBase && !needEnv) + return; + setenv("BUN_STARTUP_SNAPSHOT_REEXECED", "1", 1); + setSnapshotEnvDefaults(); + char exe[4096]; + uint32_t len = sizeof exe; + if (_NSGetExecutablePath(exe, &len) != 0) + return; + posix_spawnattr_t attr; + posix_spawnattr_init(&attr); + short flags = 0; + posix_spawnattr_getflags(&attr, &flags); + posix_spawnattr_setflags(&attr, flags | 0x0100 /* _POSIX_SPAWN_DISABLE_ASLR */ | POSIX_SPAWN_SETEXEC); + char** oargv = *_NSGetArgv(); + int argc = 0; + while (oargv[argc]) + argc++; + std::vector nargv(oargv, oargv + argc + 1); + std::string tagged = std::string(oargv[0] ? oargv[0] : exe) + kReexecTag; + nargv[0] = tagged.data(); + posix_spawn(nullptr, exe, nullptr, &attr, nargv.data(), *_NSGetEnviron()); // SETEXEC: only returns on failure + fprintf(stderr, "[snapshot] could not re-exec without ASLR; continuing slid (snapshot build/restore will not work)\n"); +#elif OS(LINUX) + // Linux: the executable is non-PIE and slid libraries are fixed up, so ASLR stays on; the re-exec only gets the allocator/JIT options into the env before startup reads them. + if (!needEnv) + return; + setenv("BUN_STARTUP_SNAPSHOT_REEXECED", "1", 1); + setSnapshotEnvDefaults(); + setenv("BUN_STARTUP_SNAPSHOT_LIB_FIXUPS", "1", 0); + { + extern char** environ; + // argv: read our own cmdline + std::vector args; + { + FILE* f = fopen("/proc/self/cmdline", "r"); + std::string cur; + int c; + while (f && (c = fgetc(f)) != EOF) { + if (!c) { + args.push_back(cur); + cur.clear(); + } else + cur += (char)c; + } + if (f) fclose(f); + } + std::vector argv; + for (auto& a : args) + argv.push_back(a.data()); + argv.push_back(nullptr); + execve("/proc/self/exe", argv.data(), environ); + } + fprintf(stderr, "[snapshot] could not re-exec to pass the allocator settings through; continuing (snapshot build/restore will not work)\n"); +#endif +} + +// `.snapshot` next to the binary is used automatically (BUN_STARTUP_SNAPSHOT=0 opts out; BUN_STARTUP_SNAPSHOT_IN overrides). +static uint64_t platformLibsBase(); +static uint64_t platformSystemLibsId(); +static uint64_t platformBuildId(); +// What a snapshot is valid for: this exact link of the executable, on a kernel with the page size its regions were cut to. +static uint64_t snapshotEnvironmentId() +{ + return platformBuildId() ^ ((uint64_t)getpagesize() * 0x9E3779B97F4A7C15ull); +} +static bool snapshotOptedOut() +{ + const char* off = getenv("BUN_STARTUP_SNAPSHOT"); + return off && (!strcmp(off, "0") || !strcmp(off, "false")); +} +static bool ownExecutablePath(char* exe, size_t cap) +{ +#if OS(DARWIN) + uint32_t len = (uint32_t)cap; + return _NSGetExecutablePath(exe, &len) == 0; +#else + ssize_t n = readlink("/proc/self/exe", exe, cap - 1); + if (n <= 0) + return false; + exe[n] = 0; + return true; +#endif +} +static bool findSiblingSnapshot(char* out, size_t cap) +{ + char exe[4096]; + if (snapshotOptedOut() || !ownExecutablePath(exe, sizeof exe)) + return false; + snprintf(out, cap, "%s.snapshot", exe); + return access(out, R_OK) == 0; +} +static bool siblingSnapshotExists() +{ + char path[4300]; + return findSiblingSnapshot(path, sizeof path); +} + +extern "C" bool Bun__standaloneEmbeddedStartupSnapshot(const uint8_t** outPtr, size_t* outLen); +static bool embeddedSnapshotExists() +{ + if (snapshotOptedOut()) + return false; + const uint8_t* p; + size_t n; + return Bun__standaloneEmbeddedStartupSnapshot(&p, &n); +} +// "@" for a snapshot embedded in the __BUN/.bun section (in-memory pointer -> segment -> file offset). +static bool findEmbeddedSnapshot(char* out, size_t cap) +{ + if (snapshotOptedOut()) + return false; + const uint8_t* p = nullptr; + size_t n = 0; + if (!Bun__standaloneEmbeddedStartupSnapshot(&p, &n)) return false; + char exe[4096]; + int64_t fileOff = -1; + uintptr_t a = (uintptr_t)p; +#if OS(DARWIN) + uint32_t len = sizeof exe; + if (_NSGetExecutablePath(exe, &len) != 0) return false; + const struct mach_header_64* mh = &_mh_execute_header; + intptr_t slide = 0; + for (uint32_t i = 0; i < _dyld_image_count(); i++) + if ((const struct mach_header_64*)_dyld_get_image_header(i) == mh) { + slide = _dyld_get_image_vmaddr_slide(i); + break; + } + const uint8_t* lc = (const uint8_t*)(mh + 1); + for (uint32_t i = 0; i < mh->ncmds; i++) { + const struct load_command* c = (const struct load_command*)lc; + if (c->cmd == LC_SEGMENT_64) { + const struct segment_command_64* sc = (const struct segment_command_64*)c; + uintptr_t lo = sc->vmaddr + slide; + if (a >= lo && a < lo + sc->vmsize && (a - lo) < sc->filesize) { + fileOff = (int64_t)(sc->fileoff + (a - lo)); + break; + } + } + lc += c->cmdsize; + } +#elif OS(LINUX) + ssize_t r = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (r <= 0) return false; + exe[r] = 0; + { // the appended payload is mapped by a PT_LOAD the ELF writer adds: pointer -> that segment's file offset + struct Ctx { + uintptr_t a; + int64_t off; + } ctx { a, -1 }; + dl_iterate_phdr([](struct dl_phdr_info* info, size_t, void* arg) -> int { + if (info->dlpi_name && *info->dlpi_name) return 0; // main executable only + Ctx* c = (Ctx*)arg; + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) & ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_LOAD) continue; + uintptr_t lo = info->dlpi_addr + ph.p_vaddr; + if (c->a >= lo && c->a < lo + ph.p_memsz && (c->a - lo) < ph.p_filesz) { + c->off = (int64_t)(ph.p_offset + (c->a - lo)); + return 1; + } + } + return 0; + }, + &ctx); + fileOff = ctx.off; + } +#else + return false; +#endif + if (fileOff < 0 || (fileOff & (getpagesize() - 1))) { + fprintf(stderr, "[snapshot] embedded snapshot is not page-aligned in the file (offset %lld); ignoring\n", (long long)fileOff); + return false; + } + snprintf(out, cap, "%s@%lld", exe, (long long)fileOff); + return true; +} + +extern "C" void Bun__startupSnapshotMaybeRestore() +{ + const bool secondGeneration = getenv("BUN_STARTUP_SNAPSHOT_REEXECED"); + unsetenv("BUN_STARTUP_SNAPSHOT_REEXECED"); // consumed: processes this one spawns must make their own re-exec decision + snapshotTimingMark(secondGeneration ? "main reached (second generation)" : "main reached (first generation)"); + // Only compiled executables carry or sit next to snapshots; a plain `bun` takes part only when asked to through the environment. + if (!bun_is_compiled_executable() && !getenv("BUN_STARTUP_SNAPSHOT_IN") && !getenv("BUN_STARTUP_SNAPSHOT_OUT")) + return; + // No setenv()/heap use in a process that is about to restore: environ would be reallocated into memory the snapshot overlays. + bool wantSnapshot = getenv("BUN_STARTUP_SNAPSHOT_IN") || getenv("BUN_STARTUP_SNAPSHOT_OUT") || siblingSnapshotExists() || embeddedSnapshotExists(); + if (wantSnapshot) + reexecWithoutASLRIfSlid(); // returns only once we are the unslid process with the snapshot env in place + char path[4200] = ""; + if (const char* in = getenv("BUN_STARTUP_SNAPSHOT_IN")) + snprintf(path, sizeof path, "%s", in); // explicit file (debugging / dev loop) + else if (!getenv("BUN_STARTUP_SNAPSHOT_OUT")) { + if (!findSiblingSnapshot(path, sizeof path)) { + path[0] = 0; + findEmbeddedSnapshot(path, sizeof path); + } // a sibling .snapshot (debugging), else the one embedded in this executable + } + s_snapshotActive = path[0] || getenv("BUN_STARTUP_SNAPSHOT_OUT"); + if (path[0]) + snapshotRestoreAndRun(path); // returns only if the snapshot was declined (then we boot normally, still with snapshot-compatible options so a rebuild can snapshot) +} +extern "C" void Bun__startupSnapshotSetBuilding(bool); +extern "C" void mi_prof_reinit_lock(void); +extern "C" void mi_os_hint_floor(void*) noexcept; +extern "C" bool mi_prof_lock_is_free(void); +extern "C" void Bun__requestSnapshot(JSC::VM*, const char* path); +static bool snapshotDump(JSC::VM& vm, const char* path); +// envGate (take() option): NUL-separated names stored after the region data, hashed with their values so a launch that differs in any of them declines before mapping anything. +static std::string s_envGateNames; +extern "C" void Bun__startupSnapshotSetEnvGate(const uint8_t* names, size_t len) { s_envGateNames.assign((const char*)names, len); } +static uint64_t envGateHash(const char* names, size_t len) +{ + uint64_t h = 1469598103934665603ull; + auto mix = [&](const char* p, size_t n) { for (size_t i = 0; i < n; i++) { h ^= (uint8_t)p[i]; h *= 1099511628211ull; } h ^= 0xff; h *= 1099511628211ull; }; + for (size_t i = 0; i < len;) { + const char* name = names + i; + size_t nl = strnlen(name, len - i); + mix(name, nl); + if (const char* v = getenv(name)) + mix(v, strlen(v)); + else + mix("\x01unset", 6); + i += nl + 1; + } + return h ? h : 1; +} +extern "C" void Bun__startupSnapshotUnwindJS(JSC::VM* vm) { vm->notifyNeedTermination(); } +extern "C" void Bun__startupSnapshotClearTerminationRequest(JSC::VM* vm) { vm->clearHasTerminationRequest(); } +extern "C" bool Bun__startupSnapshotDumpNow(JSC::VM* vm, const char* path) +{ + mi_scavenger_stop(); // joins mimalloc's background thread: nothing may hold allocator locks while we freeze +#if OS(DARWIN) + // Pool workers were told to exit; give them (bounded) time to actually be gone, and any straggler inside the allocator time to leave it. + for (int attempt = 0; attempt < 200; attempt++) { + thread_act_array_t threads; + mach_msg_type_number_t count = 0; + unsigned pool = 0; + if (task_threads(mach_task_self(), &threads, &count) == KERN_SUCCESS) { + for (mach_msg_type_number_t i = 0; i < count; i++) { + pthread_t pt = pthread_from_mach_thread_np(threads[i]); + char name[64] = ""; + if (pt) pthread_getname_np(pt, name, sizeof name); + if (!strncmp(name, "Bun Pool", 8)) pool++; + mach_port_deallocate(mach_task_self(), threads[i]); + } + vm_deallocate(mach_task_self(), (vm_address_t)threads, count * sizeof(thread_act_t)); + } + if (!pool && mi_prof_lock_is_free()) break; + usleep(10000); + } + { // who else is alive right now? every one of them is a potential holder of some lock we are about to freeze + thread_act_array_t threads; + mach_msg_type_number_t count = 0; + if (snapshotVerbose() && task_threads(mach_task_self(), &threads, &count) == KERN_SUCCESS) { + fprintf(stderr, "[snapshot] %u threads at snapshot time:", count); + for (mach_msg_type_number_t i = 0; i < count; i++) { + pthread_t pt = pthread_from_mach_thread_np(threads[i]); + char name[64] = "?"; + if (pt) pthread_getname_np(pt, name, sizeof name); + fprintf(stderr, " [%s]", name[0] ? name : "unnamed"); + mach_port_deallocate(mach_task_self(), threads[i]); + } + fprintf(stderr, "\n"); + vm_deallocate(mach_task_self(), (vm_address_t)threads, count * sizeof(thread_act_t)); + } + } +#else + // Pool workers were told to exit; give them (bounded) time to actually be gone, and any straggler inside the allocator time to leave it. + for (int attempt = 0; attempt < 200; attempt++) { + unsigned pool = 0; + if (DIR* d = opendir("/proc/self/task")) { + while (struct dirent* e = readdir(d)) { + if (e->d_name[0] == '.') continue; + char pth[128], name[64] = ""; + snprintf(pth, sizeof pth, "/proc/self/task/%s/comm", e->d_name); + if (FILE* f = fopen(pth, "r")) { + if (fgets(name, sizeof name, f) && !strncmp(name, "Bun Pool", 8)) pool++; + fclose(f); + } + } + closedir(d); + } + if (!pool && mi_prof_lock_is_free()) break; + usleep(10000); + } +#endif + if (!mi_prof_lock_is_free()) fprintf(stderr, "[snapshot] WARNING: mimalloc profiler lock is held at snapshot time (some thread is mid-free)\n"); + { // the termination that unwound JS to get us here is done with; none of it may persist into the snapshot (it would read as "terminating" forever on restore) + JSC::JSLockHolder lock(*vm); + vm->clearHasTerminationRequest(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(*vm); + scope.clearException(); + vm->traps().clearTrap(JSC::VMTraps::NeedTermination); + } + return snapshotDump(*vm, path); +} +extern "C" uint32_t Bun__standaloneStartupSnapshotBuildFlags(); +extern "C" void Bun__startupSnapshotRunMain(JSC::JSGlobalObject*); +extern "C" bool Bun__startupSnapshotHasMain(); +// `bun build --snapshot` marks the payload (Flags::TAKE_STARTUP_SNAPSHOT…); a marked run writes `.snapshot` instead of starting the app. Translated here into the runtime's internal variables, so the app's own env/argv are never involved. +static void applySnapshotBuildMarking() +{ + if (!bun_is_compiled_executable() || getenv("BUN_STARTUP_SNAPSHOT_OUT")) + return; + uint32_t bits = Bun__standaloneStartupSnapshotBuildFlags(); + if (!(bits & 1)) + return; + char exe[4096]; +#if OS(DARWIN) + uint32_t len = sizeof exe; + if (_NSGetExecutablePath(exe, &len) != 0) + return; +#else + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (n <= 0) + return; + exe[n] = 0; +#endif + char out[4096 + 16]; + snprintf(out, sizeof out, "%s.snapshot", exe); + setenv("BUN_STARTUP_SNAPSHOT_OUT", out, 1); + if (!(bits & 2)) + setenv("BUN_STARTUP_SNAPSHOT_AUTO", "1", 1); + if (bits & 8) + setenv("BUN_STARTUP_SNAPSHOT_IO", "network", 1); + else if (bits & 4) + setenv("BUN_STARTUP_SNAPSHOT_IO", "local", 1); +} + +extern "C" void Bun__startupSnapshotInit() +{ + applySnapshotBuildMarking(); + if (getenv("BUN_STARTUP_SNAPSHOT_OUT")) { + if (!Bun__startupSnapshotSupported()) { + fprintf(stderr, "error: %s\n", "startup snapshots are not available in this build of bun (macOS with mimalloc as the process allocator, and glibc Linux)"); + exit(1); + } + Bun__startupSnapshotSetBuilding(true); + } + startupSnapshotToolingInstall(); +} + +namespace Bun::StartupSnapshot { +// Bun.startupSnapshot.reclean(): pages this process dirtied and then restored to their original bytes go back to the clean file mapping. +void recleanFrozenPages(JSC::VM& vm) +{ +#if OS(DARWIN) || OS(LINUX) + JSC::JSLockHolder lock(vm); + if (snapshotFd < 0) + return; + const size_t pg = getpagesize(); + std::vector orig(pg); +#if OS(DARWIN) + std::vector disp; +#endif + size_t dirty = 0, remapped = 0; +#if OS(DARWIN) + auto pageIsDirty = [&](size_t i) { return (disp[i] & (VM_PAGE_QUERY_PAGE_DIRTY | VM_PAGE_QUERY_PAGE_COPIED)) != 0; }; +#else + int pagemap = open("/proc/self/pagemap", O_RDONLY | O_CLOEXEC); + if (pagemap < 0) + return; + std::vector pm; + auto pageIsDirty = [&](size_t i) { return (pm[i] & (1ull << 63)) && !(pm[i] & (1ull << 61)); }; // present and no longer the file's page: a private copy +#endif + auto pageIsPristine = [&](const FrozenRun& run, size_t i) { + return ipread(snapshotFd, orig.data(), pg, run.fileOff + i * pg) == (ssize_t)pg && !memcmp((const void*)(run.start + i * pg), orig.data(), pg); + }; + for (auto& run : snapshotRuns) { + const size_t n = run.len / pg; +#if OS(DARWIN) + disp.assign(n, 0); + mach_vm_size_t cnt = n; + if (mach_vm_page_range_query(mach_task_self(), run.start, run.len, (mach_vm_address_t)disp.data(), &cnt) != KERN_SUCCESS) + continue; +#else + pm.assign(n, 0); + if (::pread(pagemap, pm.data(), n * sizeof(uint64_t), (off_t)(run.start / pg) * sizeof(uint64_t)) != (ssize_t)(n * sizeof(uint64_t))) + continue; +#endif + for (size_t i = 0; i < n;) { + if (!pageIsDirty(i)) { + i++; + continue; + } + dirty++; + if (!pageIsPristine(run, i)) { + i++; + continue; + } + size_t j = i + 1; // coalesce a run of pristine dirty pages into one mapping + while (j < n && pageIsDirty(j) && pageIsPristine(run, j)) { + dirty++; + j++; + } + if (immap((void*)(run.start + i * pg), (j - i) * pg, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, snapshotFd, run.fileOff + i * pg) != MAP_FAILED) + remapped += j - i; + i = j; + } + } +#if OS(LINUX) + close(pagemap); +#endif + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) + fprintf(stderr, "[snapshot] reclean: %zu dirty snapshot pages, %zu were pristine and are file-backed again\n", dirty, remapped); +#else + UNUSED_PARAM(vm); +#endif +} +} // namespace Bun::StartupSnapshot +extern "C" void Bun__startupSnapshotRecleanPages(JSC::VM* vm) { Bun::StartupSnapshot::recleanFrozenPages(*vm); } + +struct us_loop_t; +extern "C" void us_loop_reinit_for_snapshot(struct us_loop_t*); +extern "C" struct us_loop_t* uws_get_loop(); +extern "C" void Bun__startupSnapshotContinueEventLoop(); +extern "C" void uws_adopt_loop_for_current_thread(struct us_loop_t*); +void _mi_scavenger_forked_child(void); // C++-mangled (mimalloc is built as C++ here) +void _mi_scavenger_start_if_forked(void); +extern "C" void Bun__startupSnapshotAdoptMainThreadVM(); +struct BunLaunchContext { + size_t argc; + const char* const* argv; +}; +extern "C" void bun_launch_context_capture(BunLaunchContext*); +extern "C" void bun_launch_context_restore(const BunLaunchContext*); +extern "C" void Bun__VM__refreshStackBoundsAfterSnapshotRestore(JSC::VM* vm) +{ + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] refreshing VM stack bounds: lastStackTop=%p thread stack=[%p,%p)\n", vm->lastStackTop(), WTF::Thread::currentSingleton().stack().end(), WTF::Thread::currentSingleton().stack().origin()); + vm->refreshStackBoundsAfterSnapshotRestore(); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] refreshed: lastStackTop=%p\n", vm->lastStackTop()); +} +// Snapshot-capable = a compiled executable (BUN_COMPILED is readable before main) or an explicit env request: drives the deterministic allocator hints and fixed JIT pool, so compiled apps need no environment. + +extern "C" char** environ; + +#if OS(LINUX) +#include +// Linker/loader-owned data in our own snapshot (.got, .got.plt, .init_array, .fini_array): process-specific, never program state — keep this process's copy across the overlay. +static size_t platformLinkerOwnedRanges(uint64_t (*out)[2], size_t cap) +{ + size_t n = 0; + int fd = open("/proc/self/exe", O_RDONLY); + if (fd < 0) return 0; + Elf64_Ehdr eh; + if (::pread(fd, &eh, sizeof eh, 0) != (ssize_t)sizeof eh || !eh.e_shnum) { + close(fd); + return 0; + } + std::vector sh(eh.e_shnum); + ::pread(fd, sh.data(), eh.e_shnum * sizeof(Elf64_Shdr), eh.e_shoff); + std::vector names(sh[eh.e_shstrndx].sh_size); + ::pread(fd, names.data(), names.size(), sh[eh.e_shstrndx].sh_offset); + for (auto& sec : sh) { + if (sec.sh_name >= names.size() || !sec.sh_addr) continue; + const char* nm = names.data() + sec.sh_name; + if (!strcmp(nm, ".got") || !strcmp(nm, ".got.plt") || !strcmp(nm, ".init_array") || !strcmp(nm, ".fini_array") || !strcmp(nm, ".preinit_array")) { + if (n < cap) { + out[n][0] = sec.sh_addr; + out[n][1] = sec.sh_addr + sec.sh_size; + n++; + } + } + } + // Copy relocations: libc variables (__libc_stack_end, program_invocation_name, environ, ...) that a non-PIE executable hosts in its + // own .bss. They describe this process (glibc derives the main thread's stack bounds from __libc_stack_end), so they are kept too. + for (auto& sec : sh) { + if (sec.sh_type != SHT_RELA || sec.sh_link >= sh.size() || sh[sec.sh_link].sh_type != SHT_DYNSYM) continue; + std::vector relas(sec.sh_size / sizeof(Elf64_Rela)); + std::vector syms(sh[sec.sh_link].sh_size / sizeof(Elf64_Sym)); + if (::pread(fd, relas.data(), relas.size() * sizeof(Elf64_Rela), sec.sh_offset) != (ssize_t)(relas.size() * sizeof(Elf64_Rela))) continue; + if (::pread(fd, syms.data(), syms.size() * sizeof(Elf64_Sym), sh[sec.sh_link].sh_offset) != (ssize_t)(syms.size() * sizeof(Elf64_Sym))) continue; +#if CPU(ARM64) + constexpr uint32_t copyType = R_AARCH64_COPY; +#else + constexpr uint32_t copyType = R_X86_64_COPY; +#endif + for (auto& r : relas) { + if (ELF64_R_TYPE(r.r_info) != copyType || n >= cap) continue; + uint32_t si = ELF64_R_SYM(r.r_info); + uint64_t size = si < syms.size() && syms[si].st_size ? syms[si].st_size : 8; + out[n][0] = r.r_offset; + out[n][1] = r.r_offset + ((size + 7) & ~7ull); + n++; + } + } + close(fd); + return n; +} +#else +static size_t platformLinkerOwnedRanges(uint64_t (*out)[2], size_t cap) // Darwin: the malloc zone libsystem registered for this process (see alloc-override-zone.c) +{ +#if !defined(BUN_MIMALLOC_ZONE_OVERRIDE) + (void)out; + (void)cap; + return 0; +#else + uintptr_t tmp[8][2]; + size_t n = mi_malloc_zone_process_owned_ranges(tmp, std::min(cap, 8)); + for (size_t i = 0; i < n; i++) { + out[i][0] = tmp[i][0]; + out[i][1] = tmp[i][1]; + } + return n; +#endif +} +#endif + +// Platform seam: region walk, residency, data segments, JIT copy. +struct PlatformRegion { + uint64_t addr, size; + bool writable, executable, anon, shared, isStack, isMallocZone, isGuard; + int tag; + unsigned pagesResident, pagesDirtied, pagesSwapped; +}; +#if OS(DARWIN) +template static void platformEnumerateRegions(F&& f) +{ + mach_vm_address_t addr = 0; + for (;;) { + mach_vm_size_t size = 0; + vm_region_extended_info_data_t info; + mach_msg_type_number_t count = VM_REGION_EXTENDED_INFO_COUNT; + mach_port_t objName; + if (mach_vm_region(mach_task_self(), &addr, &size, VM_REGION_EXTENDED_INFO, (vm_region_info_t)&info, &count, &objName) != KERN_SUCCESS) break; + int tag = info.user_tag; + PlatformRegion r { addr, size, !!(info.protection & VM_PROT_WRITE), !!(info.protection & VM_PROT_EXECUTE), info.external_pager == 0, info.share_mode == SM_SHARED, + tag == VM_MEMORY_STACK, tag >= VM_MEMORY_MALLOC && tag <= VM_MEMORY_MALLOC_NANO, tag == VM_MEMORY_GUARD || tag == 22, tag, info.pages_resident, info.pages_dirtied, info.pages_swapped_out }; + f(r); + addr += size; + } +} +static bool platformResidentPages(uint64_t addr, uint64_t size, std::vector& disp) +{ + size_t pg = getpagesize(); + disp.assign(size / pg, 0); + mach_vm_size_t dispCount = disp.size(); + return mach_vm_page_range_query(mach_task_self(), addr, size, (mach_vm_address_t)disp.data(), &dispCount) == KERN_SUCCESS; +} +template static void platformDataSegments(F&& f) +{ + const struct mach_header_64* mh = (const struct mach_header_64*)_dyld_get_image_header(0); + for (const char* seg : { "__DATA_CONST", "__DATA", "__DATA_DIRTY", "__AUTH", "__AUTH_CONST" }) { + unsigned long segSize = 0; + uint8_t* segData = getsegmentdata(mh, seg, &segSize); + if (segData && segSize) f((uint64_t)segData, (uint64_t)segSize); + } +} +static void platformWriteJIT(void* dst, const void* src, size_t len) +{ + pthread_jit_write_protect_np(0); + memcpy(dst, src, len); + pthread_jit_write_protect_np(1); + sys_icache_invalidate(dst, len); +} +static bool platformIsJITRegion(const PlatformRegion& r) { return r.tag == 64 && r.executable && r.anon; } +static uint64_t platformTextBase() { return (uint64_t)&_mh_execute_header; } +extern "C" const void* _dyld_get_shared_cache_range(size_t* length); +extern "C" bool _dyld_get_shared_cache_uuid(uuid_t uuid); +// System libraries' load address: snapshot words that point into them (ICU vtables, pthread main-thread handle, ...) are only valid while this matches. +static uint64_t platformLibsBase() +{ + size_t len = 0; + return (uint64_t)_dyld_get_shared_cache_range(&len); +} +static uint64_t platformSystemLibsId() +{ + uuid_t u; + if (!_dyld_get_shared_cache_uuid(u)) return 0; + uint64_t h = 1469598103934665603ull; + for (size_t i = 0; i < sizeof u; i++) { + h ^= u[i]; + h *= 1099511628211ull; + } + return h ? h : 1; +} // identity of the OS's dyld shared cache: same across reboots (it only slides), different after an OS update +// Identity of this exact executable (a snapshot is only valid for the binary that produced it): LC_UUID folded to 64 bits. +static uint64_t platformBuildId() +{ + const struct mach_header_64* mh = &_mh_execute_header; + const uint8_t* p = (const uint8_t*)(mh + 1); + for (uint32_t i = 0; i < mh->ncmds; i++) { + const struct load_command* lc = (const struct load_command*)p; + if (lc->cmd == LC_UUID) { + uint64_t a, b; + memcpy(&a, ((const struct uuid_command*)lc)->uuid, 8); + memcpy(&b, ((const struct uuid_command*)lc)->uuid + 8, 8); + return a ^ b; + } + p += lc->cmdsize; + } + return 0; +} +#elif OS(LINUX) +extern "C" char __executable_start[]; +template static void platformEnumerateRegions(F&& f) +{ + FILE* maps = fopen("/proc/self/maps", "r"); + if (!maps) return; + char line[512]; + while (fgets(line, sizeof line, maps)) { + unsigned long lo, hi, off, inode = 0; + char perms[8] = "", dev[16] = ""; + char path[256] = ""; + if (sscanf(line, "%lx-%lx %7s %lx %15s %lu %255s", &lo, &hi, perms, &off, dev, &inode, path) < 6) continue; + bool anon = inode == 0 && (path[0] == 0 || path[0] == '['); + PlatformRegion r { lo, hi - lo, perms[1] == 'w', perms[2] == 'x', anon, perms[3] == 's', !strncmp(path, "[stack", 6), false, perms[0] == '-' && perms[1] == '-', 0, 1, 1, 0 }; + // Linux has no VM tags: callers identify "ours" by address windows; JIT by the fixed pool address. + f(r); + } + fclose(maps); +} +static bool platformResidentPages(uint64_t addr, uint64_t size, std::vector& disp) +{ + size_t pg = getpagesize(); + std::vector vec(size / pg); + disp.assign(size / pg, 0); + if (mincore((void*)addr, size, vec.data())) return false; + for (size_t i = 0; i < vec.size(); i++) + disp[i] = vec[i] & 1; + return true; +} +extern "C" char _end[]; // linker-defined end of .bss: everything the injector appended to the segment (payload blocks, live or superseded) lies past it +template static void platformDataSegments(F&& f) +{ + // The writable PT_LOADs (.data/.bss/GOT: -z norelro), cut at _end: what the injector appended past it (payload blocks) is file-backed and identical in every launch. + struct Ctx { + F* f; + uint64_t end; + } ctx { &f, (uint64_t)_end }; + dl_iterate_phdr([](struct dl_phdr_info* info, size_t, void* arg) -> int { + if (info->dlpi_name && *info->dlpi_name) return 0; // main executable only + auto& ctx = *static_cast(arg); + size_t pg = getpagesize(); + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) & ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_LOAD || !(ph.p_flags & PF_W)) continue; + uint64_t lo = (info->dlpi_addr + ph.p_vaddr) & ~(uint64_t)(pg - 1); + uint64_t hi = (info->dlpi_addr + ph.p_vaddr + ph.p_memsz + pg - 1) & ~(uint64_t)(pg - 1); + if (ctx.end > lo && ctx.end < hi) hi = (ctx.end + pg - 1) & ~(uint64_t)(pg - 1); + if (hi > lo) (*ctx.f)(lo, hi - lo); + } + return 0; + }, + &ctx); +} +static void platformWriteJIT(void* dst, const void* src, size_t len) +{ + memcpy(dst, src, len); + __builtin___clear_cache((char*)dst, (char*)dst + len); +} +static bool platformIsJITRegion(const PlatformRegion& r) { return r.executable && r.anon && r.addr >= 0x3c0000000ull && r.addr < 0x400000000ull; } // BUN_STARTUP_SNAPSHOT_JIT_ADDR window +static uint64_t platformTextBase() { return (uint64_t)__executable_start; } +static uint64_t platformLibsBase() { return (uint64_t)dlsym(RTLD_DEFAULT, "getpid"); } // libc's slide stands in for all system libs +static uint64_t platformSystemLibsId() { return 0; } // Linux: per-library name+size matching in the fixup table is the identity +extern "C" char __etext[] __attribute__((weak)); +extern "C" char etext[]; +static uint64_t platformBuildId() // the ELF NT_GNU_BUILD_ID note (identity of this exact link), folded to 64 bits; falls back to the text extent +{ + struct Ctx { + uint64_t id; + } ctx { 0 }; + dl_iterate_phdr([](struct dl_phdr_info* info, size_t, void* arg) -> int { + if (info->dlpi_name && *info->dlpi_name) return 0; // main executable only + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) & ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_NOTE) continue; + const uint8_t* p = (const uint8_t*)(info->dlpi_addr + ph.p_vaddr); + const uint8_t* end = p + ph.p_memsz; + while (p + sizeof(ElfW(Nhdr)) <= end) { + const ElfW(Nhdr)* nh = (const ElfW(Nhdr)*)p; + const uint8_t* name = p + sizeof *nh; + const uint8_t* desc = name + ((nh->n_namesz + 3) & ~3u); + if (nh->n_type == NT_GNU_BUILD_ID && nh->n_namesz == 4 && !memcmp(name, "GNU", 4) && desc + nh->n_descsz <= end) { + uint64_t h = 1469598103934665603ull; + for (uint32_t k = 0; k < nh->n_descsz; k++) { + h ^= desc[k]; + h *= 1099511628211ull; + } + ((Ctx*)arg)->id = h; + return 1; + } + p = desc + ((nh->n_descsz + 3) & ~3u); + } + } + return 0; + }, + &ctx); + return ctx.id ? ctx.id : (uint64_t)((char*)etext - (char*)__executable_start); +} +#endif +// Resident pool pages the allocator has actually handed out (freed pages are MADV_FREE'd and may still read as present); its occupancy is page-granular, hence two samples per page. +static bool jitLivePages(uint64_t addr, uint64_t size, size_t pg, std::vector& disp) +{ + if (!platformResidentPages(addr, size, disp)) return false; + Locker locker { JSC::ExecutableAllocator::singleton().getLock() }; + for (size_t i = 0; i < disp.size(); i++) + if (disp[i] && !JSC::ExecutableAllocator::singleton().isValidExecutableMemory(locker, (void*)(addr + i * pg)) && !JSC::ExecutableAllocator::singleton().isValidExecutableMemory(locker, (void*)(addr + i * pg + pg / 2))) disp[i] = 0; + return true; +} +static size_t jitLivePageCount(size_t pg) +{ + size_t n = 0; + platformEnumerateRegions([&](const PlatformRegion& r) { + if (!platformIsJITRegion(r)) return; + std::vector disp; + if (jitLivePages(r.addr, r.size, pg, disp)) n += std::count(disp.begin(), disp.end(), 1); + }); + return n; +} + +// Loaded system libraries as (base, end, nameHash): snapshot words pointing into them are recorded at dump and rebased at restore. +struct PlatformLib { + uint64_t base, end, nameHash; + uint64_t flags; + char path[232]; + char seg[16]; +}; // flags bit 0: lives in the dyld shared cache (slides with it as a unit; needs no dlopen to know where it went) // path: what to dlopen when the restoring process has not loaded the library yet (apps dlopen e.g. libsqlite3 lazily); matching uses nameHash + size +static void platformLibSetName(PlatformLib& l, const char* path, const char* seg) +{ + snprintf(l.path, sizeof l.path, "%s", path ? path : ""); + snprintf(l.seg, sizeof l.seg, "%s", seg ? seg : ""); +} +static uint64_t fnv1a(const char* p) +{ + uint64_t h = 1469598103934665603ull; + for (; *p; p++) { + h ^= (uint8_t)*p; + h *= 1099511628211ull; + } + return h; +} +#if OS(LINUX) +static std::vector platformSystemLibs() +{ + std::vector libs; + dl_iterate_phdr([](struct dl_phdr_info* info, size_t, void* arg) -> int { + auto* libs = static_cast*>(arg); + const char* name = info->dlpi_name; + if (!name || !*name) return 0; // main executable: fixed (non-PIE) + uint64_t lo = UINT64_MAX, hi = 0; + for (int i = 0; i < info->dlpi_phnum; i++) + if (info->dlpi_phdr[i].p_type == PT_LOAD) { + uint64_t a = info->dlpi_addr + info->dlpi_phdr[i].p_vaddr; + lo = std::min(lo, a); + hi = std::max(hi, a + info->dlpi_phdr[i].p_memsz); + } + if (hi > lo) { + const char* slash = strrchr(name, '/'); + const char* bn = slash ? slash + 1 : name; + PlatformLib l { lo, hi, fnv1a(bn), 0, {}, {} }; + platformLibSetName(l, name, nullptr); + libs->push_back(l); + } + return 0; + }, + &libs); + return libs; +} +#else +static std::vector platformSystemLibs() // Darwin: every segment of every loaded dylib (they all live in the dyld shared cache, which slides as a unit per boot; per-segment ranges keep the pointer scan tight) +{ + std::vector libs; + for (uint32_t i = 0, n = _dyld_image_count(); i < n; i++) { + const struct mach_header_64* mh = (const struct mach_header_64*)_dyld_get_image_header(i); + if (!mh || mh == &_mh_execute_header) continue; + intptr_t slide = _dyld_get_image_vmaddr_slide(i); + const char* name = _dyld_get_image_name(i); + const char* slash = name ? strrchr(name, '/') : nullptr; + uint64_t nameHash = fnv1a(slash ? slash + 1 : (name ? name : "?")); + bool inCache = (mh->flags & MH_DYLIB_IN_CACHE) != 0; + const uint8_t* lc = (const uint8_t*)(mh + 1); + for (uint32_t j = 0; j < mh->ncmds; j++) { + const struct load_command* c = (const struct load_command*)lc; + if (c->cmd == LC_SEGMENT_64) { + const struct segment_command_64* sc = (const struct segment_command_64*)c; + if (sc->vmsize && strcmp(sc->segname, "__PAGEZERO")) { + uint64_t base = sc->vmaddr + (uint64_t)slide, end = base + sc->vmsize; + bool dup = false; + for (auto& l : libs) + if (l.base == base && l.end == end) { + dup = true; + break; + } + if (!dup) { + PlatformLib l { base, end, nameHash ^ fnv1a(sc->segname), inCache ? 1ull : 0ull, {}, {} }; + platformLibSetName(l, name, sc->segname); + libs.push_back(l); + } + } + } + lc += c->cmdsize; + } + } + return libs; +} +#endif +struct StartupSnapshotFixup { + uint64_t addr; + uint64_t lib; +}; +struct SnapshotFixupHeader { + char magic[8]; + uint64_t nlibs; + uint64_t nfixups; +}; // then PlatformLib[nlibs] (base/end/nameHash as recorded), StartupSnapshotFixup[nfixups] + +// Header CPU-feature word: latched SIMD dispatch in vendored code means a snapshot is only used on a CPU with at least the builder's features. +static uint64_t platformCpuFeatures() +{ + uint64_t f = 0; +#if CPU(X86_64) + unsigned a, b, c, d; + auto cpuid = [&](unsigned leaf, unsigned sub) { __asm__ volatile("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d) : "a"(leaf), "c"(sub)); }; + cpuid(1, 0); + f |= (uint64_t)(c & ((1u << 0) | (1u << 9) | (1u << 19) | (1u << 20) | (1u << 23) | (1u << 25) | (1u << 28))); // sse3 ssse3 sse4.1 sse4.2 popcnt aes avx + cpuid(7, 0); + f |= (uint64_t)(b & ((1u << 3) | (1u << 5) | (1u << 8) | (1u << 16) | (1u << 17) | (1u << 30) | (1u << 31))) << 32; // bmi1 avx2 bmi2 avx512f avx512dq avx512bw avx512vl + f |= 1ull << 30; // "x86-64" tag: a bit neither leaf uses (leaf 1 occupies 0-28, leaf 7 is shifted to 32-63) +#elif CPU(ARM64) +#if OS(DARWIN) + const char* keys[] = { "hw.optional.arm.FEAT_AES", "hw.optional.arm.FEAT_SHA256", "hw.optional.arm.FEAT_CRC32", "hw.optional.arm.FEAT_LSE", "hw.optional.arm.FEAT_DotProd", "hw.optional.arm.FEAT_SHA3", "hw.optional.arm.FEAT_I8MM", "hw.optional.arm.FEAT_BF16", "hw.optional.arm.FEAT_SME", "hw.optional.arm.FEAT_SVE" }; + for (unsigned i = 0; i < sizeof keys / sizeof *keys; i++) { + int v = 0; + size_t n = sizeof v; + if (!sysctlbyname(keys[i], &v, &n, nullptr, 0) && v) f |= 1ull << i; + } +#elif OS(LINUX) + f = getauxval(AT_HWCAP) & 0xffffffffull; + f |= (getauxval(AT_HWCAP2) & 0x7fffffffull) << 32; +#endif + f |= 1ull << 63; // "arm64" tag: HWCAP2 is masked to 31 bits (32-62) and the Darwin keys use low bits, so nothing else reaches 63 +#endif + return f; +} + +// A snapshot taken after the program ran is only valid for the argv it ran with (or `exe subcommand` would get the restored REPL); see main() for the exemption. +static uint64_t snapshotArgvKey() +{ + uint64_t h = 1469598103934665603ull; + int argc = 0; + char** argv = nullptr; +#if OS(DARWIN) + argc = *_NSGetArgc(); + argv = *_NSGetArgv(); +#elif OS(LINUX) + static std::vector args; + static std::vector ptrs; + if (ptrs.empty()) { + FILE* f = fopen("/proc/self/cmdline", "r"); + std::string cur; + int c; + while (f && (c = fgetc(f)) != EOF) { + if (!c) { + args.push_back(cur); + cur.clear(); + } else + cur.push_back((char)c); + } + if (f) fclose(f); + for (auto& a : args) + ptrs.push_back(a.data()); + } + argc = (int)ptrs.size(); + argv = ptrs.data(); +#endif + for (int i = 1; i < argc; i++) { + for (const char* p = argv[i]; *p; p++) { + h ^= (uint8_t)*p; + h *= 1099511628211ull; + } + h ^= 0xff; + h *= 1099511628211ull; + } + return h ^ ((uint64_t)(argc > 0 ? argc - 1 : 0) << 56) ^ 0x5a5a; // never 0 +} + +struct StartupSnapshotHeader { + char magic[8]; + uint64_t textBase; + uint64_t vm; + uint64_t globalObject; + uint64_t mainThread; + uint64_t nregions; + uint64_t reserved[8]; + uint64_t libsBase; + uint64_t spare[7]; +}; // 176 bytes; region table follows +struct StartupSnapshotRegion { + uint64_t addr; + uint64_t len; + uint64_t fileOff; + uint64_t kind; +}; // kind: 0 heap(anon), 1 __DATA segment + +// First-writer trap: snapshot pages are made read-only; the fault handler records the writer's stack, unprotects the page and resumes. + +// A dup'd controlling-tty fd to recreate at restore: fd, the F_GETFL word, and source stdio+1 in disjoint fields (an overlapping layout let x86-64's O_LARGEFILE bleed into the fd number). +static uint64_t ttyFdRecord(int fd, int flags, int src) { return ((uint64_t)(uint32_t)fd << 40) | ((uint64_t)(uint32_t)flags << 8) | (uint64_t)(src + 1); } +static void ttyFdRecordUnpack(uint64_t v, int& fd, int& flags, int& src) +{ + fd = (int)(v >> 40); + flags = (int)((v >> 8) & 0xffffffffu); + src = (int)(v & 0xff) - 1; +} + +static struct termios s_snapshotTermios; +static int s_snapshotTermiosFd = -1; // lives in __DATA, so it travels inside the snapshot +static uint64_t s_snapshotOpenFds[16]; // fds 0..1023 open in the build process: the restored process parks /dev/null on them so stale closes are harmless and new fds never alias them +struct SnapshotFileFd { + int fd; + int flags; + char path[1024]; // F_GETPATH needs MAXPATHLEN +}; +static SnapshotFileFd s_snapshotFileFds[32]; +static int s_snapshotFileFdCount = 0; // writable regular files (logs) get reopened O_APPEND at the same fd number +static bool snapshotDump(JSC::VM& vm, const char* path) +{ +#if OS(DARWIN) || OS(LINUX) + JSC::JSLockHolder lock(vm); + { + Vector gated; + for (size_t start = 0; start < s_envGateNames.size();) { + size_t end = s_envGateNames.find('\0', start); + if (end == std::string::npos) + end = s_envGateNames.size(); + gated.append(String::fromUTF8(std::span { s_envGateNames.data() + start, end - start })); + start = end + 1; + } + Bun::printEnvReadsBeforeSnapshot(defaultGlobalObject(), gated); + } + s_snapshotTermiosFd = -1; + for (int fd = 0; fd < 3; fd++) + if (isatty(fd) && !tcgetattr(fd, &s_snapshotTermios)) { + s_snapshotTermiosFd = fd; + break; + } + memset(s_snapshotOpenFds, 0, sizeof s_snapshotOpenFds); + s_snapshotFileFdCount = 0; + for (int fd = 3; fd < 1024; fd++) { + if (fcntl(fd, F_GETFD) == -1) continue; + s_snapshotOpenFds[fd / 64] |= 1ull << (fd % 64); + struct stat st; + if (s_snapshotFileFdCount < 32 && !fstat(fd, &st) && S_ISREG(st.st_mode)) { + SnapshotFileFd& f = s_snapshotFileFds[s_snapshotFileFdCount]; + f.fd = fd; + f.flags = fcntl(fd, F_GETFL); +#if OS(DARWIN) + if ((f.flags & O_ACCMODE) != O_RDONLY && fcntl(fd, F_GETPATH, f.path) != -1) s_snapshotFileFdCount++; +#else + { + char lnk[64]; + snprintf(lnk, sizeof lnk, "/proc/self/fd/%d", fd); + ssize_t n = readlink(lnk, f.path, sizeof f.path - 1); + if ((f.flags & O_ACCMODE) != O_RDONLY && n > 0) { + f.path[n] = 0; + s_snapshotFileFdCount++; + } + } +#endif + } + } + size_t settledStrings = 0; + { // Error objects keep raw StackFrames (CodeBlock pointers) until .stack is first read; resolve them now so nothing in the snapshot points at code we drop or re-link + JSC::HeapIterationScope scope(vm.heap); + vm.heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (isJSCellKind(kind)) { + JSC::JSCell* cell = static_cast(heapCell); + if (auto* error = dynamicDowncast(cell)) error->materializeErrorInfoIfNeeded(vm); + // Lazy one-time StringImpl header writes (hash, did-report-cost) would otherwise dirty snapshot pages the first time a string is used after restore. + if (auto* str = dynamicDowncast(cell)) { + if (!str->isRope()) + if (auto* impl = str->tryGetValueImpl()) { + impl->settleLazyHeaderWritesForStartupSnapshot(); + settledStrings++; + } + } + } + return IterationStatus::Continue; + }); + } + if (auto* table = vm.atomStringTable()) + for (auto& packed : table->table()) + if (auto* impl = packed.get()) { + impl->settleLazyHeaderWritesForStartupSnapshot(); + settledStrings++; + } + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] settled %zu StringImpl headers\n", settledStrings); + { + // Compiled JS is per-run hot state: a long-running program is 11-17 MB/process lighter re-creating it, a main() tool starts 15-20% faster keeping it. BUN_STARTUP_SNAPSHOT_DELETE_CODE=0|linked overrides. + const char* dc = getenv("BUN_STARTUP_SNAPSHOT_DELETE_CODE"); + vm.completeAllJITPlansBeforeStartupSnapshot(); + JSC::sanitizeStackForVM(vm); + bool keep = dc ? !strcmp(dc, "0") : Bun__startupSnapshotHasMain(); + if (keep) { + } else if (dc && !strcmp(dc, "linked")) + vm.deleteAllLinkedCode(JSC::DeleteAllCodeIfNotCollecting); + else + vm.deleteAllCode(JSC::DeleteAllCodeIfNotCollecting); + } + vm.heap.freezeCurrentHeapAsImmortalStartupSnapshot(); // GC never writes snapshot blocks again (frozen marks = liveness, side remembered set) + mi_option_set(mi_option_purge_delay, 0); + mi_collect(true); // free spans get decommitted so "resident" below means "snapshot payload" + size_t pg = getpagesize(); + startupSnapshotToolingIndexAtFreeze(vm, pg); + std::vector> freeRanges; // arena slices in no page: free memory, whatever the kernel says about residency + mi_arenas_visit_free_ranges(mi_heap_main(), [](void* start, size_t size, void* arg) { static_cast>*>(arg)->push_back({ (uintptr_t)start, (uintptr_t)start + size }); }, &freeRanges); + std::sort(freeRanges.begin(), freeRanges.end()); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) { + size_t fb = 0; + for (auto& r : freeRanges) + fb += r.second - r.first; + if (snapshotVerbose()) fprintf(stderr, "[snapshot] arena free ranges: %zu, %.1fMB\n", freeRanges.size(), fb / 1048576.0); + } + auto inFreeRange = [&](uintptr_t a) { auto it = std::upper_bound(freeRanges.begin(), freeRanges.end(), std::make_pair(a, UINTPTR_MAX)); return it != freeRanges.begin() && a < std::prev(it)->second; }; + std::vector regions; + size_t jitPagesAtScan = 0; + // 1. anonymous writable regions we own (mimalloc arenas + page map, JSC/WTF OS allocations in the hint windows) + the JIT pool + platformEnumerateRegions([&](const PlatformRegion& r) { + uint64_t addr = r.addr, size = r.size; + int tag = r.tag; + // Only memory we own and place deterministically. Kernel-placed libSystem regions belong to the *new* process and must not be overlaid. + bool ours = tag == 240 || tag == 63 || tag == 65 || (addr >= 0x1f000000000ull && addr < 0x30000000000ull) || (addr >= 0x2e0000000000ull && addr < 0x2f0000000000ull); + if (platformIsJITRegion(r)) { + regions.push_back({ addr, size, 0, ((uint64_t)tag << 8) | 3 }); // reservation, no data + std::vector disp; + if (jitLivePages(addr, size, pg, disp)) { + jitPagesAtScan += std::count(disp.begin(), disp.end(), 1); + for (size_t i = 0; i < disp.size();) { + if (!disp[i]) { + i++; + continue; + } + size_t j = i; + while (j < disp.size() && disp[j]) + j++; + regions.push_back({ addr + i * pg, (j - i) * pg, 0, ((uint64_t)tag << 8) | 2 }); + i = j; + } + } + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] JIT region %llx+%llx resident=%u dirty=%u\n", (unsigned long long)addr, (unsigned long long)size, r.pagesResident, r.pagesDirtied); + } else if (ours && r.writable && !r.executable && r.anon && !r.isStack && !r.isMallocZone && !r.isGuard && !r.shared) { + // Emitted even when nothing in it is resident: pointers into it may exist (an allocator table that is still all zeros), so the mapping itself must come back. + regions.push_back({ addr, size, 0, ((uint64_t)tag << 8) | 4 }); // anonymous reserve, then resident runs as file-backed data + std::vector disp; + if (platformResidentPages(addr, size, disp)) { + auto live = [&](size_t k) { return disp[k] && !inFreeRange(addr + k * pg); }; // purged spans can still read as present; mimalloc knows they are free + for (size_t i = 0; i < disp.size();) { + if (!live(i)) { + i++; + continue; + } + size_t j = i; + while (j < disp.size() && live(j)) + j++; + regions.push_back({ addr + i * pg, (j - i) * pg, 0, (uint64_t)tag << 8 }); + i = j; + } + } else + regions.back().kind = (uint64_t)tag << 8; + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] region %llx+%llx tag=%d resident=%u dirty=%u\n", (unsigned long long)addr, (unsigned long long)size, tag, r.pagesResident, r.pagesDirtied); + } + }); + // 2. main binary data segments (globals of Bun/JSC/WTF/mimalloc) + platformDataSegments([&](uint64_t a, uint64_t len) { regions.push_back({ a, (len + pg - 1) & ~(uint64_t)(pg - 1), 0, 1 }); }); + // drop anon regions overlapping __DATA entries (region scan sees them as file-backed anyway) and our own stack + uintptr_t sp = (uintptr_t)__builtin_frame_address(0); + std::vector out; + for (auto& r : regions) { + if (r.kind == 0 && sp >= r.addr && sp < r.addr + r.len) continue; + // Address-adjacent heap runs (and reservations) restore as one mapping; the enumeration yields them in address order. + unsigned k = r.kind & 0xff; + if (!out.empty() && (k == 0 || k == 4) && (out.back().kind & 0xff) == k && out.back().addr + out.back().len == r.addr) { + out.back().len += r.len; + continue; + } + out.push_back(r); + } + int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + fprintf(stderr, "[snapshot] open %s failed\n", path); + return false; + } + StartupSnapshotHeader hdr {}; + memcpy(hdr.magic, "BUNSNAP1", 8); + hdr.textBase = platformTextBase(); + hdr.libsBase = platformLibsBase(); + hdr.spare[0] = snapshotEnvironmentId(); + hdr.spare[2] = platformCpuFeatures(); + // With main() registered the program has not run yet, so the snapshot fits any invocation (0 = no argv check); otherwise it holds this invocation's state. + hdr.spare[3] = Bun__startupSnapshotHasMain() ? 0 : snapshotArgvKey(); + hdr.spare[4] = platformSystemLibsId(); + hdr.vm = (uint64_t)&vm; + hdr.globalObject = (uint64_t)defaultGlobalObject(); + hdr.mainThread = (uint64_t)&WTF::Thread::currentSingleton(); + hdr.reserved[0] = (uint64_t)mi_theap_get_default(); // main thread's mimalloc theap (TLS-referenced, lives in the heap) + hdr.reserved[7] = (uint64_t)uws_get_loop(); // main thread's uWS loop (TLS-referenced) + { + pthread_key_t k = 0; + if (!pthread_key_create(&k, nullptr)) { + hdr.reserved[1] = (uint64_t)k; + pthread_key_delete(k); + } + } // high-water mark of pthread TLS keys + { // fds that are the controlling TTY (dup'd stdin/stdout readers): the restoring process recreates them from its own 0/1/2 + struct stat st[3]; + bool have[3]; + for (int i = 0; i < 3; i++) + have[i] = !fstat(i, &st[i]) && S_ISCHR(st[i].st_mode); + int n = 0; + for (int fd = 3; fd < 256 && n < 5; fd++) { + struct stat fs; + if (fstat(fd, &fs) || !S_ISCHR(fs.st_mode)) continue; + int fl = fcntl(fd, F_GETFL); + int src = -1; + for (int i = 0; i < 3; i++) + if (have[i] && fs.st_rdev == st[i].st_rdev) { + src = ((fl & O_ACCMODE) == O_RDONLY) ? 0 : (i == 0 ? 1 : i); + break; + } + if (src < 0) continue; + hdr.reserved[2 + n++] = ttyFdRecord(fd, fl, src); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] tty fd %d (flags %x) <- std%d\n", fd, fl, src); + } + } + hdr.nregions = out.size(); + size_t tableOff = sizeof(StartupSnapshotHeader); + size_t dataOff = (tableOff + out.size() * sizeof(StartupSnapshotRegion) + pg - 1) & ~(pg - 1); + size_t fileOff = dataOff, total = 0; + for (auto& r : out) { + size_t used = ((r.kind & 0xff) == 3 || (r.kind & 0xff) == 4) ? 0 : r.len; + r.fileOff = fileOff; + fileOff += used; + } + mi_arenas_freeze_pages(); // from here on nothing frees into a page that is going into the snapshot (this process's remaining frees are dropped too) + { // extern-library fixups: words in the snapshot that point into a loaded system library get rebased at restore (lets libraries slide) + std::vector libs = platformSystemLibs(); + std::vector fixups; + if (!libs.empty()) { + uint64_t minB = UINT64_MAX, maxE = 0; + for (auto& l : libs) { + minB = std::min(minB, l.base); + maxE = std::max(maxE, l.end); + } + for (auto& r : out) { + unsigned k = r.kind & 0xff; + if (k == 2 || k == 3 || k == 4) continue; + const uint64_t* w = (const uint64_t*)r.addr; + size_t n = r.len / 8; + for (size_t i = 0; i < n; i++) { + uint64_t v = w[i]; + if (v < minB || v >= maxE) continue; + for (size_t li = 0; li < libs.size(); li++) + if (v >= libs[li].base && v < libs[li].end) { + fixups.push_back({ r.addr + i * 8, li }); + break; + } + } + } + } + { // Keep only the segments something actually points into (a process has ~1.7K loaded segments; a snapshot references a few dozen). + std::vector newIndex(libs.size(), UINT64_MAX); + std::vector used; + for (auto& f : fixups) { + if (newIndex[f.lib] == UINT64_MAX) { + newIndex[f.lib] = used.size(); + used.push_back(libs[f.lib]); + } + f.lib = newIndex[f.lib]; + } + libs.swap(used); + } + SnapshotFixupHeader fh {}; + memcpy(fh.magic, "BUNFIX3", 8); + fh.nlibs = libs.size(); + fh.nfixups = fixups.size(); + size_t fixOff = (fileOff + 4095) & ~4095ull; + hdr.spare[1] = fixOff; + pwrite(fd, &fh, sizeof fh, fixOff); + pwrite(fd, libs.data(), libs.size() * sizeof(PlatformLib), fixOff + sizeof fh); + pwrite(fd, fixups.data(), fixups.size() * sizeof(StartupSnapshotFixup), fixOff + sizeof fh + libs.size() * sizeof(PlatformLib)); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE") || !fixups.empty()) { + size_t pages = 0; + uint64_t last = ~0ull; + for (auto& f : fixups) { + uint64_t pg = f.addr >> 14; + if (pg != last) { + pages++; + last = pg; + } + } + if (snapshotVerbose()) fprintf(stderr, "[snapshot] %zu extern-library fixups across %zu library segments, touching %zu 16K pages (%.1f MB dirtied at restore if libraries slid)\n", fixups.size(), libs.size(), pages, pages * 16384.0 / 1048576.0); + } + } + for (auto& r : out) { + // write region contents; non-resident anon pages read as zero which is what a fresh mapping would give anyway + size_t used = ((r.kind & 0xff) == 3 || (r.kind & 0xff) == 4) ? 0 : r.len; + if (pwrite(fd, (void*)r.addr, used, r.fileOff) != (ssize_t)used) { + fprintf(stderr, "[snapshot] pwrite failed for %llx+%llx errno %d\n", r.addr, (unsigned long long)used, errno); + } + total += used; + } + if (!s_envGateNames.empty()) { + struct stat cur; + fstat(fd, &cur); + size_t gateOff = ((size_t)cur.st_size + 4095) & ~4095ull; + pwrite(fd, s_envGateNames.data(), s_envGateNames.size(), gateOff); + hdr.spare[5] = (uint64_t)gateOff | ((uint64_t)s_envGateNames.size() << 40); + hdr.spare[6] = envGateHash(s_envGateNames.data(), s_envGateNames.size()); + } + // Background compilers were quiesced before the walk; if code was installed anyway the snapshot points at code it lacks, and no snapshot beats that one. + if (size_t now = jitLivePageCount(pg); now != jitPagesAtScan) { + fprintf(stderr, "[snapshot] error: executable memory changed while the snapshot was being written (%zu pages live at the walk, %zu now): something was still compiling; not writing a snapshot\n", jitPagesAtScan, now); + close(fd); + unlink(path); + return false; + } + pwrite(fd, &hdr, sizeof hdr, 0); + pwrite(fd, out.data(), out.size() * sizeof(StartupSnapshotRegion), tableOff); + close(fd); + fprintf(stderr, "[snapshot] wrote %s: %zu regions, %.1fMB (vm=%p global=%p thread=%p text=%p)\n", path, out.size(), total / 1048576.0, (void*)hdr.vm, (void*)hdr.globalObject, (void*)hdr.mainThread, (void*)hdr.textBase); + return true; +#else + UNUSED_PARAM(vm); + UNUSED_PARAM(path); + return false; +#endif +} + +// Restore: called from Bun__startupSnapshotMaybeRestore (very early in main) when BUN_STARTUP_SNAPSHOT_IN is set. Never returns. +static void snapshotRestoreAndRun(const char* path) +{ + snapshotTimingMark("restore begins (after the re-exec, if any)"); +#if OS(DARWIN) || OS(LINUX) + char filePath[4200]; + snprintf(filePath, sizeof filePath, "%s", path); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) { + void* probe = malloc(64); + if (snapshotVerbose()) fprintf(stderr, "[snapshot] pre-restore heap probe=%p (expected >= 0x21000000000, above where snapshot regions go)\n", probe); + free(probe); + } + snapshotBaseOff = 0; + if (char* at = strrchr(filePath, '@')) { + char* end = nullptr; + long long o = strtoll(at + 1, &end, 10); + if (end && !*end && o > 0) { + *at = 0; + snapshotBaseOff = (off_t)o; + } + } // "@": snapshot embedded in a bigger file (our own executable) + int fd = open(filePath, O_RDONLY); + if (fd < 0) { + fprintf(stderr, "[snapshot] cannot open %s\n", path); + _exit(2); + } + StartupSnapshotHeader hdr; + ipread(fd, &hdr, sizeof hdr, 0); + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] source %s base=%lld magic=%.7s nregions=%llu text=%llx libs=%llx build=%llx\n", filePath, (long long)snapshotBaseOff, hdr.magic, (unsigned long long)hdr.nregions, (unsigned long long)hdr.textBase, (unsigned long long)hdr.libsBase, (unsigned long long)hdr.spare[0]); + if (memcmp(hdr.magic, "BUNSNAP1", 8) || hdr.spare[0] != snapshotEnvironmentId()) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] %s was not produced by this build of the executable (or by one on a different page size); booting normally\n", path); + close(fd); + return; + } + if (hdr.spare[5]) { + size_t gateOff = hdr.spare[5] & ((1ull << 40) - 1), gateLen = hdr.spare[5] >> 40; + char names[4096]; + if (gateLen == 0 || gateLen > sizeof names || ipread(fd, names, gateLen, gateOff) != (ssize_t)gateLen) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] unreadable environment gate; booting normally\n"); + close(fd); + return; + } + if (envGateHash(names, gateLen) != hdr.spare[6]) { + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] environment differs from the build in a gated variable; booting normally\n"); + close(fd); + return; + } + } + if (hdr.spare[3] && hdr.spare[3] != snapshotArgvKey() && !getenv("BUN_STARTUP_SNAPSHOT_IN")) { + if (getenv("BUN_STARTUP_SNAPSHOT_VERBOSE")) fprintf(stderr, "[snapshot] argv differs from the build invocation; booting normally\n"); + close(fd); + return; + } + { + uint64_t need = hdr.spare[2], have = platformCpuFeatures(); + if (need && (have & need) != need) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] %s was built on a CPU with features this one lacks (%llx vs %llx); booting normally\n", path, (unsigned long long)need, (unsigned long long)have); + close(fd); + return; + } + } + if (hdr.textBase != platformTextBase()) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] ASLR slide differs (snapshot text %llx vs ours %llx); booting normally\n", (unsigned long long)hdr.textBase, (unsigned long long)platformTextBase()); + close(fd); + return; + } + // Extern-library fixup table. Storage is anonymous mmap, not heap: the allocator's memory is about to be overlaid by the snapshot. + constexpr size_t kMaxPendingLibs = 64; + const char* pendingLibs[kMaxPendingLibs]; + size_t nPendingLibs = 0; + bool haveFixups = false; + int64_t* libDelta = nullptr; + size_t nLibDelta = 0; + StartupSnapshotFixup* fixups = nullptr; + size_t nFixups = 0; + if (hdr.spare[1]) { + SnapshotFixupHeader fh; + if (ipread(fd, &fh, sizeof fh, hdr.spare[1]) == (ssize_t)sizeof fh && !memcmp(fh.magic, "BUNFIX3", 8) && fh.nlibs < 4096 && fh.nfixups < (1u << 24)) { + size_t bytes = (fh.nlibs * (sizeof(PlatformLib) + sizeof(int64_t)) + fh.nfixups * sizeof(StartupSnapshotFixup) + 16383) & ~16383ull; + uint8_t* buf = (uint8_t*)mmap(nullptr, bytes ? bytes : 16384, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + PlatformLib* recorded = (PlatformLib*)buf; + libDelta = (int64_t*)(recorded + fh.nlibs); + fixups = (StartupSnapshotFixup*)(libDelta + fh.nlibs); + nLibDelta = fh.nlibs; + nFixups = fh.nfixups; + ipread(fd, recorded, fh.nlibs * sizeof(PlatformLib), hdr.spare[1] + sizeof fh); + ipread(fd, fixups, fh.nfixups * sizeof(StartupSnapshotFixup), hdr.spare[1] + sizeof fh + fh.nlibs * sizeof(PlatformLib)); + std::vector now = platformSystemLibs(); // heap use is fine up to here (before the overlay) + // Libraries the builder loaded during its run (dlopen: CoreFoundation/CoreServices for fs.watch, libsqlite3, …) whose + // initializers therefore never ran in this process. Their code is mapped either way (shared cache) and the snapshot's + // pointers into them are rebased below, but they must be dlopen'd here too — after the overlay, in the same + // position in process history the builder loaded them — so their per-process state (CF allocators, ObjC classes) + // exists when snapshotted code calls into them. Paths are collected now (heap is still ours), opened after the overlay. + for (size_t i = 0; i < fh.nlibs && nPendingLibs < kMaxPendingLibs; i++) { + recorded[i].path[sizeof recorded[i].path - 1] = 0; + if (!recorded[i].path[0]) continue; + bool present = false; + for (auto& l : now) + if (l.nameHash == recorded[i].nameHash) { + present = true; + break; + } + if (present) continue; + if (!(recorded[i].flags & 1)) { // not part of the shared cache: load it now so its segments have addresses to match against below (its initializers run here, before the overlay, exactly as they would have if it were linked) + bool used = false; + for (size_t k = 0; k < fh.nfixups; k++) + if (fixups[k].lib == i) { + used = true; + break; + } + if (used && dlopen(recorded[i].path, RTLD_NOW | RTLD_GLOBAL)) { + now = platformSystemLibs(); + } + continue; + } + bool dup = false; + for (size_t j = 0; j < nPendingLibs; j++) + if (!strcmp(pendingLibs[j], recorded[i].path)) { + dup = true; + break; + } + if (!dup) pendingLibs[nPendingLibs++] = recorded[i].path; // points into `buf`, which stays mapped through the restore + } + haveFixups = true; + for (size_t i = 0; i < fh.nlibs; i++) { + libDelta[i] = 0; + bool found = false; + for (auto& l : now) + if (l.nameHash == recorded[i].nameHash && (l.end - l.base) == (recorded[i].end - recorded[i].base)) { + libDelta[i] = (int64_t)l.base - (int64_t)recorded[i].base; + found = true; + break; + } + if (!found && (recorded[i].flags & 1) && hdr.libsBase) { + libDelta[i] = (int64_t)platformLibsBase() - (int64_t)hdr.libsBase; + found = true; + } // not loaded here (the builder dlopen'd it); it is mapped with the cache regardless, at the cache's current slide + if (!found) { + bool used = false; + for (size_t k = 0; k < nFixups; k++) + if (fixups[k].lib == i) { + used = true; + break; + } + if (used) { + bool present = false; + for (auto& l : now) + if (l.nameHash == recorded[i].nameHash) { + present = true; + break; + } + if (snapshotVerbose()) fprintf(stderr, "[snapshot] system library %s (%s) the snapshot points into %s; booting normally\n", recorded[i].path, recorded[i].seg, present ? "changed size" : "could not be loaded"); + close(fd); + return; + } + } + } + } + } + bool fixupsWanted = haveFixups && !(getenv("BUN_STARTUP_SNAPSHOT_LIB_FIXUPS") && !strcmp(getenv("BUN_STARTUP_SNAPSHOT_LIB_FIXUPS"), "0")); // system libraries may slide between boots (Darwin: the dyld shared cache; Linux: ASLR per exec) + if (fixupsWanted && hdr.spare[4] && hdr.spare[4] != platformSystemLibsId()) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] %s was built against a different OS build (system library contents changed); booting normally\n", path); + close(fd); + return; + } + if (!fixupsWanted && hdr.libsBase && hdr.libsBase != platformLibsBase()) { + if (snapshotVerbose()) fprintf(stderr, "[snapshot] %s was built against system libraries at %llx, now at %llx (reboot / OS update); booting normally\n", path, (unsigned long long)hdr.libsBase, (unsigned long long)platformLibsBase()); + close(fd); + return; + } + mi_scavenger_stop(); // this process's scavenger thread must not touch allocator state while/after we overlay it + // No heap use from here until the overlay is done: with malloc routed to mimalloc, this process's heap sits at the same VA as the snapshot's. + if (hdr.nregions > 8192) { + fprintf(stderr, "[snapshot] too many regions\n"); + _exit(2); + } + StartupSnapshotRegion* regionsBuf = (StartupSnapshotRegion*)mmap(nullptr, (hdr.nregions * sizeof(StartupSnapshotRegion) + 16383) & ~16383ull, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); // not heap, not __DATA: both get overlaid below + ipread(fd, regionsBuf, hdr.nregions * sizeof(StartupSnapshotRegion), sizeof(StartupSnapshotHeader)); + std::span regions(regionsBuf, hdr.nregions); + { // this process's own (pre-overlay) allocator must not place anything where the snapshot goes: push mimalloc's hint pointer above the snapshot + uint64_t top = 0; + for (auto& r : regions) + if (r.addr >= 0x20000000000ull && r.addr < 0x2e0000000000ull) top = std::max(top, r.addr + r.len); + if (top) mi_os_hint_floor((void*)(top + (1ull << 30))); + } + const off_t savedBaseOff = snapshotBaseOff; // snapshotBaseOff lives in __DATA, which the overlay below rewrites with the builder's value + BunLaunchContext launch; + bun_launch_context_capture(&launch); // this process's raw argc/argv (our statics get the builder's below) + uint64_t hintFloorAfterOverlay = 0; + { + uint64_t top = 0; + for (auto& r : regions) + if (r.addr >= 0x20000000000ull && r.addr < 0x2e0000000000ull) top = std::max(top, r.addr + r.len); + hintFloorAfterOverlay = (top ? top : 0x20000000000ull) + (1ull << 30); + } + size_t mapped = 0, copied = 0; + struct DataSeg { + uint64_t* dst; + const uint64_t* src; + size_t words; + }; + DataSeg dataSegs[16]; + size_t nDataSegs = 0; // no heap here: the allocator's state is being overlaid + bool useLibFixups = fixupsWanted; + uint64_t linkerRanges[96][2]; + size_t nLinkerRanges = platformLinkerOwnedRanges(linkerRanges, 96); + const bool deferDataCopy = useLibFixups || nLinkerRanges > 0; // words this process owns inside our data segments are skipped by the deferred copy + bool verbose = !!getenv("BUN_STARTUP_SNAPSHOT_VERBOSE"); + for (auto& r : regions) { + if (verbose) { + fprintf(stderr, "[snapshot] restoring %llx+%llx kind=%llu tag=%llu\n", r.addr, r.len, r.kind & 0xff, r.kind >> 8); + } + if ((r.kind & 0xff) == 3) { + void* m = mmap((void*)r.addr, r.len, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANON | MAP_JIT, -1, 0); // MAP_JIT|MAP_FIXED is EINVAL; rely on the hint + if (m != (void*)r.addr) { + fprintf(stderr, "[snapshot] mmap JIT %llx+%llx landed at %p errno %d\n", r.addr, r.len, m, errno); + _exit(3); + } + continue; + } + if ((r.kind & 0xff) == 2) { + void* buf = mmap(nullptr, r.len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (ipread(fd, buf, r.len, r.fileOff) != (ssize_t)r.len) { + fprintf(stderr, "[snapshot] pread JIT failed errno %d\n", errno); + _exit(3); + } + platformWriteJIT((void*)r.addr, buf, r.len); + munmap(buf, r.len); + copied += r.len; + continue; + } + if ((r.kind & 0xff) == 4) { + munmap((void*)r.addr, r.len); + void* m = mmap((void*)r.addr, r.len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON | MAP_FIXED, -1, 0); + if (m == MAP_FAILED) { + fprintf(stderr, "[snapshot] mmap reserve %llx+%llx failed errno %d\n", r.addr, r.len, errno); + _exit(3); + } + continue; + } + if ((r.kind & 0xff) == 1) { + // __DATA is copied last: from then until the extern-library fixups the GOT is the builder's, so nothing in between may call into libc. + if (mprotect((void*)r.addr, r.len, PROT_READ | PROT_WRITE)) { + fprintf(stderr, "[snapshot] mprotect __DATA %llx failed errno %d\n", r.addr, errno); + _exit(3); + } + if (!deferDataCopy) { // copy in place now (nothing to skip or rebase) + if (ipread(fd, (void*)r.addr, r.len, r.fileOff) != (ssize_t)r.len) { + fprintf(stderr, "[snapshot] pread __DATA failed errno %d\n", errno); + _exit(3); + } + snapshotBaseOff = savedBaseOff; // just overwritten along with the rest of our __DATA + mi_os_hint_floor((void*)hintFloorAfterOverlay); // the builder's allocator hint pointer just arrived with __DATA; keep fresh OS memory above the snapshot + copied += r.len; + continue; + } + void* scratch = mmap(nullptr, r.len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (scratch == MAP_FAILED || ipread(fd, scratch, r.len, r.fileOff) != (ssize_t)r.len) { + fprintf(stderr, "[snapshot] pread __DATA failed errno %d\n", errno); + _exit(3); + } + if (nDataSegs < 16) dataSegs[nDataSegs++] = { (uint64_t*)r.addr, (const uint64_t*)scratch, r.len / 8 }; + copied += r.len; + continue; + } else { + void* m = immap((void*)r.addr, r.len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, fd, r.fileOff); + if (m == MAP_FAILED) { + // e.g. a reservation with restrictive max_prot already sits there: deallocate the range and retry + int e1 = errno; + munmap((void*)r.addr, r.len); + m = immap((void*)r.addr, r.len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, fd, r.fileOff); + if (m == MAP_FAILED) { + fprintf(stderr, "[snapshot] mmap %llx+%llx (tag %llu) failed errno %d then %d — skipping\n", r.addr, r.len, r.kind >> 8, e1, errno); + continue; + } + } + mapped += r.len; + } + } + // Re-seat allocator TLS: this thread's default theap must be the snapshot's main theap, not whatever this process created before the overlay. + { // libc-free critical section: overwrite our data segments with the builder's, then rebase extern-library pointers. Plain loops only (no PLT calls). + // Process-owned libc globals that live in *our* data segment (copy relocations in a non-PIE executable): keep this process's values. + char** volatile* environSlot = (char** volatile*)&environ; + char** savedEnviron = *environSlot; // volatile: the overlay below rewrites it behind the compiler's back + for (size_t di = 0; di < nDataSegs; di++) { + DataSeg& d = dataSegs[di]; + volatile uint64_t* dst = d.dst; + const uint64_t* src = d.src; + for (size_t k = 0; k < d.words; k++) { + uint64_t a = (uint64_t)(d.dst + k); + bool linkerOwned = false; + for (size_t q = 0; q < nLinkerRanges; q++) + if (a >= linkerRanges[q][0] && a < linkerRanges[q][1]) { + linkerOwned = true; + break; + } + if (!linkerOwned) dst[k] = src[k]; + } + } + if (useLibFixups) + for (size_t k = 0; k < nFixups; k++) { + StartupSnapshotFixup& f = fixups[k]; + bool linkerOwned = false; + for (size_t q = 0; q < nLinkerRanges; q++) + if (f.addr >= linkerRanges[q][0] && f.addr < linkerRanges[q][1]) { + linkerOwned = true; + break; + } + if (!linkerOwned && f.lib < nLibDelta && libDelta[f.lib]) *(volatile uint64_t*)f.addr += libDelta[f.lib]; + } + if (deferDataCopy) *environSlot = savedEnviron; + *(volatile off_t*)&snapshotBaseOff = savedBaseOff; + mi_os_hint_floor((void*)hintFloorAfterOverlay); + } + for (size_t di = 0; di < nDataSegs; di++) + munmap((void*)dataSegs[di].src, dataSegs[di].words * 8); + bun_launch_context_restore(&launch); // everything derived from it (process.argv, Bun.argv, …) is ProcessDerived and recomputes this epoch + if (hdr.reserved[0]) { + mi_theap_set_default((mi_theap_t*)hdr.reserved[0]); + mi_theap_adopt_current_thread((mi_theap_t*)hdr.reserved[0]); // the fresh heap below binds to this thread state; it must name this thread + } + _mi_scavenger_forked_child(); // same situation as a fork child: the snapshot says a scavenger runs, but no such thread exists here + mi_prof_reinit_lock(); // and any allocator-internal lock a build-process thread was holding is nobody's now + { // park /dev/null on every fd number the snapshot thinks it owns (the snapshot file fd itself gets moved out of the way first) + int hi = 1023; + while (hi > 2 && !(s_snapshotOpenFds[hi / 64] & (1ull << (hi % 64)))) + hi--; + if (fd <= hi) { + int moved = fcntl(fd, F_DUPFD_CLOEXEC, hi + 1); + if (moved >= 0) { + close(fd); + fd = moved; + } + } + for (int i = 0; i < s_snapshotFileFdCount; i++) { + SnapshotFileFd& f = s_snapshotFileFds[i]; + if (fcntl(f.fd, F_GETFD) != -1) continue; + int nfd = open(f.path, (f.flags & ~(O_CREAT | O_TRUNC | O_EXCL)) | O_APPEND | O_CLOEXEC); + if (nfd < 0) continue; + if (nfd != f.fd) { + dup2(nfd, f.fd); + close(nfd); + } + if (verbose) fprintf(stderr, "[snapshot] reopened log fd %d -> %s\n", f.fd, f.path); + } + int devnull = open("/dev/null", O_RDWR | O_CLOEXEC); + int parked = 0; + for (int k = 3; k <= hi; k++) + if ((s_snapshotOpenFds[k / 64] & (1ull << (k % 64))) && fcntl(k, F_GETFD) == -1 && dup2(devnull, k) == k) parked++; + if (devnull > hi) close(devnull); + if (verbose) fprintf(stderr, "[snapshot] parked /dev/null on %d stale fd numbers (max %d)\n", parked, hi); + } + bun_refresh_stdio_after_snapshot_restore(); // this launch's terminal state becomes what exit restores, captured before the builder's mode goes on below + if (s_snapshotTermiosFd >= 0 && isatty(s_snapshotTermiosFd)) { + tcsetattr(s_snapshotTermiosFd, TCSANOW, &s_snapshotTermios); + if (s_snapshotTermiosFd < 3) + bun_stdio_modified[s_snapshotTermiosFd] = 1; // so exit puts the shell's state back even though this process never called setRawMode + } // raw mode etc. as the build process left it + for (int i = 2; i < 7 && hdr.reserved[i]; i++) { // recreate TTY fds at their old numbers from our own stdio + int fd, fl, src; + ttyFdRecordUnpack(hdr.reserved[i], fd, fl, src); + if (isatty(src) && dup2(src, fd) == fd) { + if (fl & O_NONBLOCK) fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); + if (verbose) fprintf(stderr, "[snapshot] dup2(%d, %d) flags %x\n", src, fd, fl); + } + } + setvbuf(stderr, nullptr, _IONBF, 0); + setvbuf(stdout, nullptr, _IOLBF, 0); // stdio buffering mode was decided in the builder (whose fds may have been files) + { // Snapshot payload pages are immortal: never free into them (that would dirty a clean file-backed page for allocator metadata); allocate from fresh pages. + snapshotFd = fd; // stays open: reclean remaps pristine pages from it (and the tooling diffs against it) + { // Watchpoint.cpp asks whether an object is snapshotted; the snapshotted allocator arenas are one contiguous span + uintptr_t lo = UINTPTR_MAX, hi = 0; + for (auto& r : regions) + if ((r.kind & 0xff) == 0 && (r.kind >> 8) == 240) { + lo = std::min(lo, r.addr); + hi = std::max(hi, r.addr + r.len); + } + if (hi > lo) { + JSC::Heap::s_snapshotImmortalRangeLo = lo; + JSC::Heap::s_snapshotImmortalRangeSpan = hi - lo; + } + } + if (hdr.reserved[0]) mi_theap_freeze((mi_theap_t*)hdr.reserved[0]); + mi_arenas_seal_existing(); // every arena that exists now is snapshot memory: nobody (any thread) allocates into its free space again + uint64_t snapshottedTop = 0; // the overlay brought the builder's hint pointer (or none): fresh OS memory goes above everything snapshotted + for (auto& r : regions) + if (r.addr >= 0x20000000000ull && r.addr < 0x2e0000000000ull) snapshottedTop = std::max(snapshottedTop, r.addr + r.len); + if (snapshottedTop) mi_os_hint_floor((void*)(snapshottedTop + (1ull << 30))); + mi_heap_t* fresh = nullptr; +#if OS(DARWIN) + { // This process's allocations get their own arena, placed explicitly 1GiB above the snapshot rather than wherever the allocator's + // (just overlaid) hint state would put them. Linux relies on the sealed arenas + hint floor until exclusive-arena binding is sorted out. + mi_arena_id_t freshArena = 0; + void* want = (void*)((snapshottedTop + (1ull << 30)) & ~((1ull << 30) - 1)); + size_t sz = 1ull << 30; + void* got = mmap(want, sz, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON | MAP_NORESERVE, -1, 0); + if (got != MAP_FAILED && got != want) { + munmap(got, sz); + got = MAP_FAILED; + } + if (got != MAP_FAILED && mi_manage_os_memory_ex(got, sz, /*committed*/ false, /*large*/ false, /*zero*/ true, /*numa*/ -1, /*exclusive*/ true, &freshArena)) + fresh = mi_heap_new_in_arena(freshArena); + else if (mi_reserve_os_memory_ex(sz, false, false, true, &freshArena) == 0) + fresh = mi_heap_new_in_arena(freshArena); + mi_os_hint_floor((void*)((uintptr_t)want + 2 * sz)); + } +#endif + if (!fresh) + fresh = mi_heap_new(); + freshHeap = fresh; + mi_theap_set_default(mi_heap_theap(fresh)); + // Only now may anything allocate: these live in the fresh heap, not in the (frozen) snapshot pages they describe. + frozenRanges.clear(); + snapshotRuns.clear(); + for (auto& r : regions) + if ((r.kind & 0xff) == 0) { + frozenRanges.push_back({ r.addr, r.addr + r.len }); + snapshotRuns.push_back({ (uintptr_t)r.addr, (size_t)r.len, (size_t)r.fileOff }); + } + std::sort(frozenRanges.begin(), frozenRanges.end()); + std::sort(snapshotRuns.begin(), snapshotRuns.end(), [](const FrozenRun& x, const FrozenRun& y) { return x.start < y.start; }); + { + void* probe = mi_malloc(64); + if (verbose) fprintf(stderr, "[snapshot] fresh heap: own arena=%d probe=%p\n", (int)(fresh != nullptr), probe); + if (JSC::Heap::isInSnapshotImmortalRange(probe)) { // cannot happen with the floor above; if it ever does, misclassifying new objects as snapshotted would be worse than the rule it serves + fprintf(stderr, "[snapshot] fresh heap overlaps the snapshot span; not tracking snapshot objects\n"); + JSC::Heap::s_snapshotImmortalRangeSpan = 0; + } + mi_free(probe); + } + } + // The scavenger thread died with the build process; without it nothing sweeps parked heaps and frees stay resident — restart it as a fork child would. + _mi_scavenger_start_if_forked(); + startupSnapshotToolingAfterRestore(); + startupSnapshotToolingArmTraps(); + // pthread TLS keys created by the build process (WTF::ThreadSpecific etc.) must exist here too, or setspecific silently fails; burn keys up to the snapshot's high-water mark. The burned keys have no destructors: a thread that exits after storing into one leaks that value (accepted; the main thread never exits). + if (hdr.reserved[1]) { + for (int i = 0; i < 1024; i++) { + pthread_key_t k = 0; + if (pthread_key_create(&k, nullptr)) break; + if ((uint64_t)k + 1 >= hdr.reserved[1]) break; + } + } + for (size_t i = 0; i < nPendingLibs; i++) { // see the note where these were collected + if (!dlopen(pendingLibs[i], RTLD_NOW | RTLD_GLOBAL)) + fprintf(stderr, "[snapshot] warning: could not load %s, which the snapshot uses: %s\n", pendingLibs[i], dlerror()); + else if (verbose) + fprintf(stderr, "[snapshot] loaded %s (the builder had it loaded)\n", pendingLibs[i]); + } + if (useLibFixups && verbose) fprintf(stderr, "[snapshot] rebased %zu extern-library pointers\n", nFixups); + if (snapshotVerbose()) fprintf(stderr, "[snapshot] restored %zu regions: %.1fMB mapped clean, %.1fMB __DATA copied\n", regions.size(), mapped / 1048576.0, copied / 1048576.0); + snapshotTimingMark("regions mapped and library pointers rebased"); + // From here on all globals/heap are the build process's. Adopt the snapshot's main Thread object for this OS thread. + WTF::Thread* mainThread = (WTF::Thread*)hdr.mainThread; + mainThread->adoptCurrentThreadForStartupSnapshot(); + JSC::VM* vm = (JSC::VM*)hdr.vm; + if (snapshotVerbose()) fprintf(stderr, "[snapshot] thread: snapshot main=%p currentSingleton=%p currentMayBeNull=%p apiLock owner=%p held=%d\n", mainThread, &WTF::Thread::currentSingleton(), WTF::Thread::currentMayBeNull(), vm->apiLock().ownerThread() ? vm->apiLock().ownerThread()->get() : nullptr, (int)vm->apiLock().currentThreadIsHoldingLock()); + JSC::JSGlobalObject* globalObject = (JSC::JSGlobalObject*)hdr.globalObject; + uws_adopt_loop_for_current_thread((struct us_loop_t*)hdr.reserved[8 - 1]); // main thread's uWS::Loop TLS -> the snapshot's loop object (else uws_get_loop() would make a second loop) + us_loop_reinit_for_snapshot(uws_get_loop()); + __atomic_add_fetch(&bun_snapshot_epoch, 1, __ATOMIC_ACQ_REL); + snapshotReprobeCPUDispatch(); + Bun__startupSnapshotAdoptMainThreadVM(); + JSC::JSLockHolder restoreLock(*vm); // held until 'restore' has been emitted: releasing a JSLock drains microtasks, and snapshotted continuations must not run before the app hears about the restore + { + JSC::JSLockHolder lock(*vm); + vm->didRestoreFromStartupSnapshot(); + if (snapshotVerbose()) fprintf(stderr, "[snapshot] termination state: request=%d pendingTermException=%d exception=%p trapsNeedTermination=%d\n", (int)vm->hasTerminationRequest(), (int)vm->hasPendingTerminationException(), vm->exceptionForInspection(), (int)vm->traps().needHandling(JSC::VMTraps::NeedTermination)); + if (vm->hasPendingTerminationException() || vm->hasTerminationRequest()) { + vm->clearHasTerminationRequest(); + { + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(*vm); + scope.clearException(); + } + vm->traps().clearTrap(JSC::VMTraps::NeedTermination); + if (verbose) fprintf(stderr, "[snapshot] cleared stale termination state\n"); + } + } + + { + JSC::JSLockHolder lock(*vm); + NakedPtr exception; + globalObject->weakRandom().setSeed(WTF::cryptographicallyRandomNumber()); // Math.random's stream came from the builder + // chdir('.') refreshes libc's cached cwd; after the app's post-restore burst settles, one full GC plus a reclean hands back what it only touched transiently. + JSC::evaluate(globalObject, JSC::makeSource("try { process.chdir('.'); } catch {} process.emit('restore');"_s, JSC::SourceOrigin {}, JSC::SourceTaintedOrigin::Untainted), JSC::JSValue(), exception); + if (exception) { // reported before main() runs: main() may end the process, and a listener's failure is the likelier cause of main()'s + fprintf(stderr, "[snapshot] a 'restore' listener threw: %s\n", exception->value().toWTFString(globalObject).utf8().data()); + exception = nullptr; + } + JSC::evaluate(globalObject, JSC::makeSource("setTimeout(() => { Bun.gc(true); Bun.startupSnapshot.reclean(); }, 2000).unref();"_s, JSC::SourceOrigin {}, JSC::SourceTaintedOrigin::Untainted), JSC::JSValue(), exception); + Bun__startupSnapshotRunMain(globalObject); // the program registered with Bun.startupSnapshot.main(), if any + } + snapshotTimingMark("runtime refreshed, 'restore' emitted and main() run; entering the event loop"); + Bun__startupSnapshotContinueEventLoop(); // never returns +#endif +} + +#else // !BUN_STARTUP_SNAPSHOT_SUPPORTED + +#include +#include +namespace JSC { +class VM; +} +extern "C" int bun_is_compiled_executable(void); +extern "C" bool Bun__isCompiledExecutable() { return bun_is_compiled_executable(); } +extern "C" bool Bun__startupSnapshotMode() { return false; } +extern "C" bool Bun__startupSnapshotActive() { return false; } +extern "C" bool Bun__startupSnapshotSupported() { return false; } +extern "C" void Bun__startupSnapshotMaybeRestore() {} +extern "C" void Bun__startupSnapshotInit() +{ + if (getenv("BUN_STARTUP_SNAPSHOT_OUT")) { + fprintf(stderr, "error: %s\n", "startup snapshots are not available in this build of bun (macOS with mimalloc as the process allocator, and glibc Linux)"); + exit(1); + } +} +extern "C" void Bun__startupSnapshotSetEnvGate(const uint8_t*, size_t) {} +extern "C" void Bun__startupSnapshotRecleanPages(JSC::VM*) {} +extern "C" void Bun__VM__refreshStackBoundsAfterSnapshotRestore(JSC::VM*) {} +extern "C" void Bun__startupSnapshotUnwindJS(JSC::VM*) {} +extern "C" void Bun__startupSnapshotClearTerminationRequest(JSC::VM*) {} +extern "C" bool Bun__startupSnapshotDumpNow(JSC::VM*, const char*) +{ + fprintf(stderr, "error: snapshots are not supported on this platform\n"); + exit(1); +} + +#endif // BUN_STARTUP_SNAPSHOT_SUPPORTED +#pragma clang diagnostic pop diff --git a/src/jsc/bindings/StartupSnapshot.h b/src/jsc/bindings/StartupSnapshot.h new file mode 100644 index 000000000000..5b2b5de8f0f2 --- /dev/null +++ b/src/jsc/bindings/StartupSnapshot.h @@ -0,0 +1,65 @@ +#pragma once +// Snapshots: StartupSnapshot.cpp builds and restores them; StartupSnapshotTooling.cpp holds the attribution commands used while +// putting an application on a diet (dirty-page maps, censuses, write traps), compiled only with -DBUN_STARTUP_SNAPSHOT_TOOLING=1. +#include "root.h" + +#ifndef BUN_STARTUP_SNAPSHOT_TOOLING +#define BUN_STARTUP_SNAPSHOT_TOOLING 0 +#endif + +#if defined(__has_feature) +#if __has_feature(address_sanitizer) +#define BUN_STARTUP_SNAPSHOT_ASAN 1 +#endif +#endif +#if defined(__SANITIZE_ADDRESS__) +#define BUN_STARTUP_SNAPSHOT_ASAN 1 +#endif +// ASAN owns the fixed address ranges the snapshot heap and JIT pool are placed in. Linux support is glibc for now: the +// musl build crashes while writing the snapshot and has not been debugged yet. +#if (OS(DARWIN) || (OS(LINUX) && defined(__GLIBC__))) && !defined(BUN_STARTUP_SNAPSHOT_ASAN) +#define BUN_STARTUP_SNAPSHOT_SUPPORTED 1 +#else +#define BUN_STARTUP_SNAPSHOT_SUPPORTED 0 +#endif + +namespace JSC { +class VM; +} + +#if BUN_STARTUP_SNAPSHOT_SUPPORTED +#include +#include +#include +struct mi_heap_s; + +namespace Bun::StartupSnapshot { +struct FrozenRun { + uintptr_t start; + size_t len; + size_t fileOff; +}; +// State of the restored snapshot (empty in a process that did not restore one). +extern std::vector> frozenRanges; // sorted [start, end) +extern std::vector snapshotRuns; // the same ranges with their file offsets, sorted by address +extern int snapshotFd; // the snapshot file, kept open so pages can be compared with / remapped from it +extern ::mi_heap_s* freshHeap; // where this process allocates after a restore (null before one, or if the general path was used) +extern off_t snapshotBaseOff; // where the snapshot starts inside snapshotFd (non-zero when it is embedded in the executable) +ssize_t ipread(int fd, void* buf, size_t n, off_t off); +void* immap(void* addr, size_t len, int prot, int flags, int fd, off_t off); +void recleanFrozenPages(JSC::VM&); +} +#endif + +#if BUN_STARTUP_SNAPSHOT_TOOLING +void startupSnapshotToolingInstall(); +void startupSnapshotToolingIndexAtFreeze(JSC::VM&, size_t pageSize); +void startupSnapshotToolingArmTraps(); +void startupSnapshotToolingAfterRestore(); +extern "C" void Bun__startupSnapshotToolingTick(JSC::VM*); +#else +inline void startupSnapshotToolingInstall() {} +inline void startupSnapshotToolingIndexAtFreeze(JSC::VM&, size_t) {} +inline void startupSnapshotToolingArmTraps() {} +inline void startupSnapshotToolingAfterRestore() {} +#endif diff --git a/src/jsc/bindings/StartupSnapshotTooling.cpp b/src/jsc/bindings/StartupSnapshotTooling.cpp new file mode 100644 index 000000000000..6c4f2c25dfe1 --- /dev/null +++ b/src/jsc/bindings/StartupSnapshotTooling.cpp @@ -0,0 +1,1778 @@ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wformat" // uint64_t is unsigned long on Linux, unsigned long long on Darwin; this file prints a lot of addresses +#include "root.h" +#include "StartupSnapshot.h" +#if BUN_STARTUP_SNAPSHOT_TOOLING && BUN_STARTUP_SNAPSHOT_SUPPORTED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if OS(DARWIN) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif +#ifndef MAP_JIT +#define MAP_JIT 0 +#endif +#include +#if OS(DARWIN) +extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength(); +#endif + +extern "C" int mi_prof_dump_to_file(const char*) noexcept; +extern "C" void mi_prof_enable(size_t) noexcept; +static size_t s_profSampleRate; // set when this file enables the profiler itself; otherwise the environment says +extern "C" void mi_on_thread_idle(void) noexcept; +extern "C" void mi_purge_holes_report(void) noexcept; +typedef void(mi_output_fun)(const char* msg, void* arg); +extern "C" void mi_stats_print_out(mi_output_fun* out, void* arg) noexcept; +extern "C" void mi_arenas_print(void) noexcept; +extern "C" void mi_collect(bool force) noexcept; +extern "C" size_t mi_usable_size(const void*) noexcept; +extern "C" int mi_heap_snapshot_to_file(const char* path, unsigned flags) noexcept; +extern "C" void mi_arenas_freeze_pages() noexcept; +extern "C" void mi_prof_visit_live(bool (*cb)(uintptr_t addr, size_t size, const uintptr_t* frames, uint8_t nframes, void* arg), void* arg) noexcept; +#include +#include "ZigGlobalObject.h" +using namespace Bun::StartupSnapshot; +extern "C" void Bun__requestSnapshot(JSC::VM*, const char* path); + +static std::vector s_payloadPages; // sorted OS pages that held live malloc blocks (main heap) at freeze +static std::map s_pageSizeClass; // page -> block size of (first) live block seen +static std::set s_profileCells; // cells changed during the "training" interaction +static bool s_recordProfile = false; +static std::vector> s_liveBlocks; // (start, size) of live malloc blocks at freeze, sorted +static std::vector s_cellPages; // sorted OS pages inside MarkedBlocks at freeze +static bool recordUsedBlock(const mi_heap_t*, const mi_heap_area_t*, void* block, size_t block_size, void* arg) +{ + if (!block) return true; + size_t pg = *static_cast(arg); + for (uintptr_t a = reinterpret_cast(block) & ~(pg - 1); a < reinterpret_cast(block) + block_size; a += pg) { + s_payloadPages.push_back(a); + s_pageSizeClass.emplace(a, block_size); + } + s_liveBlocks.push_back({ reinterpret_cast(block), block_size }); + return true; +} +static bool pageIn(const std::vector& v, uintptr_t a) { return std::binary_search(v.begin(), v.end(), a); } +static std::atomic s_requested { 0 }; +static const char* s_dir = nullptr; +static int s_seq = 0; + +static void memdebugSignal(int sig) +{ + s_requested.store(sig == SIGXCPU ? 3 : +#ifdef SIGINFO + sig == SIGINFO ? 2 + : +#endif + 1); +} + +static void dumpJSCHeap(JSC::VM& vm, FILE* f) +{ + JSC::JSLockHolder lock(vm); + auto& heap = vm.heap; + fprintf(f, "heap.size\t%zu\nheap.capacity\t%zu\nheap.extraMemorySize\t%zu\nheap.blockBytesAllocated\t%zu\nobjectSpace.capacity\t%zu\nobjectSpace.size\t%zu\n", + heap.size(), heap.capacity(), heap.extraMemorySize(), heap.blockBytesAllocated(), heap.objectSpace().capacity(), heap.objectSpace().size()); + struct Entry { + size_t count { 0 }; + size_t cellBytes { 0 }; + size_t estimated { 0 }; + }; + WTF::HashMap map; + + { + JSC::HeapIterationScope scope(heap); + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + const char* name = ""; + size_t cellSize = heapCell->cellSize(); + size_t est = cellSize; + if (isJSCellKind(kind)) { + auto* cell = static_cast(heapCell); + name = cell->className(); + est = cell->estimatedSizeInBytes(vm); + } + auto& e = map.add(name, Entry {}).iterator->value; + e.count++; + e.cellBytes += cellSize; + e.estimated += est; + return IterationStatus::Continue; + }); + } + { + JSC::HeapIterationScope scope(heap); + WTF::HashSet linkedSet; + size_t codeBlocks = 0; + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* cell = static_cast(heapCell); + if (auto* cb = dynamicDowncast(cell)) { + codeBlocks++; + linkedSet.add(cb->unlinkedCodeBlock()); + } + return IterationStatus::Continue; + }); + size_t n = 0, nLinked = 0, insn = 0, insnLinked = 0, meta = 0, metaLinked = 0, nHasMeta = 0; + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* cell = static_cast(heapCell); + auto* ucb = dynamicDowncast(cell); + if (!ucb) + return IterationStatus::Continue; + bool linked = linkedSet.contains(ucb); + n++; + size_t is = ucb->instructions().sizeInBytes(); + auto& md = ucb->metadata(); + size_t ms = md.sizeInBytesForGC(); + if (ms) nHasMeta++; + insn += is; + meta += ms; + if (linked) { + nLinked++; + insnLinked += is; + metaLinked += ms; + } + return IterationStatus::Continue; + }); + fprintf(f, "\nunlinkedCodeBlocks\t%zu\nunlinkedCodeBlocks.linked\t%zu\ncodeBlocks\t%zu\ninstructionBytes\t%zu\ninstructionBytes.linked\t%zu\nmetadataBufBytes\t%zu\nmetadataBufBytes.linked\t%zu\nunlinkedWithMetadata\t%zu\n", n, nLinked, codeBlocks, insn, insnLinked, meta, metaLinked, nHasMeta); + } + { + JSC::HeapIterationScope scope(heap); + struct DirStat { + size_t blocks { 0 }; + size_t liveCells { 0 }; + size_t liveBytes { 0 }; + size_t emptyBlocks { 0 }; + size_t hist[11] {}; + }; + std::map dirs; + heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* handle) { + std::string key = std::string(handle->subspace()->name()) + "/" + std::to_string(handle->cellSize()); + auto& d = dirs[key]; + d.blocks++; + size_t live = 0; + handle->forEachLiveCell([&](size_t, JSC::HeapCell*, JSC::HeapCell::Kind) { live++; return IterationStatus::Continue; }); + d.liveCells += live; + d.liveBytes += live * handle->cellSize(); + if (!live) d.emptyBlocks++; + size_t cap = JSC::MarkedBlock::payloadSize / handle->cellSize(); + d.hist[cap ? std::min(10, live * 10 / cap) : 0]++; + }); + fprintf(f, "\ndirectory\tblocks\tblockBytes\tliveCells\tliveBytes\temptyBlocks\tutil%%\thist(0-100%% by 10)\n"); + for (auto& [k, d] : dirs) { + fprintf(f, "%s\t%zu\t%zu\t%zu\t%zu\t%zu\t%.0f\t", k.c_str(), d.blocks, d.blocks * JSC::MarkedBlock::blockSize, d.liveCells, d.liveBytes, d.emptyBlocks, d.blocks ? 100.0 * d.liveBytes / (d.blocks * JSC::MarkedBlock::payloadSize) : 0.0); + for (int i = 0; i < 11; i++) + fprintf(f, "%zu%s", d.hist[i], i < 10 ? "," : "\n"); + } + } + { + JSC::HeapIterationScope scope(heap); + std::vector> mods; + size_t totalSrc = 0; + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* rec = dynamicDowncast(static_cast(heapCell)); + if (!rec) + return IterationStatus::Continue; + size_t len = rec->sourceCode().provider() ? rec->sourceCode().provider()->source().length() : 0; + totalSrc += len; + mods.push_back({ len, std::string(rec->moduleKey().string().string().utf8().data()) }); + return IterationStatus::Continue; + }); + std::sort(mods.begin(), mods.end(), std::greater<>()); + { + std::map> perURL; // url -> (functionExecutables, everExecuted) + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* fe = dynamicDowncast(static_cast(heapCell)); + if (!fe) + return IterationStatus::Continue; + std::string url(fe->sourceURL().utf8().data()); + auto& e = perURL[url]; + e.first++; + if (fe->codeBlockForCall() || fe->codeBlockForConstruct()) + e.second++; + return IterationStatus::Continue; + }); + fprintf(f, "\nurl\tfunctionExecutables\texecutedFunctions\n"); + for (auto& [url, e] : perURL) + fprintf(f, "url\t%s\t%zu\t%zu\n", url.c_str(), e.first, e.second); + if (const char* strDump = getenv("BUN_MEMDEBUG_STR")) { + // JSString census: duplicate contents + length histogram (resolved, non-rope strings only) + std::map> byContent; // content(truncated) -> (count, bytes) + size_t ropes = 0, ropeBytes = 0, total = 0, totalBytes = 0, atoms = 0, atomBytes = 0, symbols = 0, substrings = 0; + size_t hist[8] = { 0 }; + size_t histBytes[8] = { 0 }; + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* cell = static_cast(heapCell); + if (!cell->isString()) + return IterationStatus::Continue; + auto* str = static_cast(cell); + total++; + if (str->isRope()) { + ropes++; + ropeBytes += str->length(); + return IterationStatus::Continue; + } + WTF::StringImpl* impl = str->tryGetValueImpl(); + if (!impl) return IterationStatus::Continue; + size_t bytes = impl->length() * (impl->is8Bit() ? 1 : 2); + totalBytes += bytes; + if (impl->isAtom()) { + atoms++; + atomBytes += bytes; + } + if (impl->isSymbol()) symbols++; + if (impl->bufferOwnership() == WTF::StringImpl::BufferSubstring) substrings++; + int b = bytes < 16 ? 0 : bytes < 64 ? 1 + : bytes < 256 ? 2 + : bytes < 1024 ? 3 + : bytes < 4096 ? 4 + : bytes < 65536 ? 5 + : bytes < 1048576 ? 6 + : 7; + hist[b]++; + histBytes[b] += bytes; + std::string key = impl->is8Bit() ? std::string(reinterpret_cast(impl->span8().data()), std::min(impl->length(), 120)) : std::string(WTF::String(impl).utf8().data()).substr(0, 120); + auto& e = byContent[key]; + e.first++; + e.second += bytes; + return IterationStatus::Continue; + }); + FILE* ff = fopen(strDump, "w"); + if (ff) { + fprintf(ff, "#total\t%zu\tresolvedBytes\t%zu\tropes\t%zu\tropeChars\t%zu\tatoms\t%zu\tatomBytes\t%zu\tsymbols\t%zu\tsubstrings\t%zu\n", total, totalBytes, ropes, ropeBytes, atoms, atomBytes, symbols, substrings); + const char* names[8] = { "<16", "16-64", "64-256", "256-1K", "1K-4K", "4K-64K", "64K-1M", ">1M" }; + for (int i = 0; i < 8; i++) + fprintf(ff, "#hist\t%s\t%zu\t%zu\n", names[i], hist[i], histBytes[i]); + std::vector> dups; + size_t dupWaste = 0; + for (auto& [k, v] : byContent) + if (v.first > 1) { + size_t waste = v.second - v.second / v.first; + dupWaste += waste; + dups.push_back({ waste, std::to_string(v.first) + "\t" + std::to_string(v.second) + "\t" + k }); + } + fprintf(ff, "#duplicateWasteBytes\t%zu\n", dupWaste); + std::sort(dups.begin(), dups.end(), std::greater<>()); + for (size_t i = 0; i < std::min(dups.size(), 300); i++) { + std::string line = dups[i].second; + for (auto& ch : line) + if (ch == '\n' || ch == '\r') ch = ' '; + fprintf(ff, "%zu\t%s\n", dups[i].first, line.c_str()); + } + fclose(ff); + } + } + if (const char* fnDump = getenv("BUN_MEMDEBUG_FN")) { + // live JSFunction instances grouped by executable: url \t startOffset \t instances \t everExecuted + std::map, std::pair> byExec; + size_t hostFns = 0, boundFns = 0; + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* fn = dynamicDowncast(static_cast(heapCell)); + if (!fn) + return IterationStatus::Continue; + if (fn->inherits()) { + boundFns++; + return IterationStatus::Continue; + } + auto* fe = fn->jsExecutable(); + if (!fe || fn->isHostFunction()) { + hostFns++; + return IterationStatus::Continue; + } + auto& e = byExec[{ std::string(fe->sourceURL().utf8().data()), fe->source().startOffset() }]; + e.first++; + if (fe->codeBlockForCall() || fe->codeBlockForConstruct()) e.second = 1; + return IterationStatus::Continue; + }); + FILE* ff = fopen(fnDump, "w"); + if (ff) { + fprintf(ff, "#host\t%zu\tbound\t%zu\n", hostFns, boundFns); + for (auto& [k, v] : byExec) + fprintf(ff, "%s\t%u\t%zu\t%d\n", k.first.c_str(), k.second, v.first, v.second); + fclose(ff); + } + } + if (const char* feDump = getenv("BUN_MEMDEBUG_FE")) { + FILE* ff = fopen(feDump, "w"); + if (ff) { + heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + auto* fe = dynamicDowncast(static_cast(heapCell)); + if (!fe) + return IterationStatus::Continue; + fprintf(ff, "%s\t%u\t%d\t%d\n", fe->sourceURL().utf8().data(), fe->source().startOffset(), fe->firstLine(), (fe->codeBlockForCall() || fe->codeBlockForConstruct()) ? 1 : 0); + return IterationStatus::Continue; + }); + fclose(ff); + } + } + } + fprintf(f, "\nmodules\t%zu\ttotalSourceBytes\t%zu\n", mods.size(), totalSrc); + for (auto& [len, key] : mods) + fprintf(f, "module\t%zu\t%s\n", len, key.c_str()); + } + fprintf(f, "\nclass\tcount\tcellBytes\testimatedBytes\n"); + for (auto& [name, e] : map) + fprintf(f, "%s\t%zu\t%zu\t%zu\n", name, e.count, e.cellBytes, e.estimated); +} + +static void fileSnapshotHeap(JSC::VM& vm) +{ + JSC::JSLockHolder lock(vm); + bool freeze = !getenv("BUN_FILESNAP_NOFREEZE"); + if (freeze) + vm.heap.freezeCurrentHeapAsImmortalStartupSnapshot(); + else + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + mi_collect(true); + if (freeze && !freshHeap) { // as the real restore does, so the census can tell post-"restore" malloc from snapshot memory + freshHeap = mi_heap_new(); + mi_theap_set_default(mi_heap_theap(freshHeap)); + } + size_t pg = getpagesize(); + bool onlyLive = !getenv("BUN_FILESNAP_ALL"); + snapshotRuns.clear(); + startupSnapshotToolingIndexAtFreeze(vm, pg); + struct Range { + uintptr_t start; + size_t len; + }; + std::vector candidates; +#if OS(DARWIN) + { + mach_vm_address_t addr = 0; + for (;;) { + mach_vm_size_t size = 0; + vm_region_extended_info_data_t info; + mach_msg_type_number_t count = VM_REGION_EXTENDED_INFO_COUNT; + mach_port_t objName; + if (mach_vm_region(mach_task_self(), &addr, &size, VM_REGION_EXTENDED_INFO, (vm_region_info_t)&info, &count, &objName) != KERN_SUCCESS) + break; + // anonymous, writable, private, has dirty pages, tagged by mimalloc (100/240) or untagged malloc-ish; skip stacks/JIT/mapped files + bool writable = (info.protection & VM_PROT_WRITE) && !(info.protection & VM_PROT_EXECUTE); + bool anon = info.external_pager == 0; + int tag = info.user_tag; + bool tagOk = tag == 100 || tag == 240 || tag == 0 /* untagged */; + if (writable && anon && tagOk && info.pages_dirtied > 0 && size >= 1 * 1024 * 1024 && info.share_mode != SM_SHARED) + candidates.push_back({ (uintptr_t)addr, (size_t)size }); + addr += size; + } + } +#else + { + FILE* maps = fopen("/proc/self/maps", "r"); + char line[512]; + while (maps && fgets(line, sizeof line, maps)) { + unsigned long a, b; + char perms[8]; + unsigned long off; + char dev[16]; + unsigned long inode; + char path[256] = ""; + if (sscanf(line, "%lx-%lx %7s %lx %15s %lu %255s", &a, &b, perms, &off, dev, &inode, path) < 6) continue; + if (perms[0] != 'r' || perms[1] != 'w' || perms[2] == 'x') continue; + if (inode != 0) continue; // file-backed already + if (path[0] == '[') continue; // [stack] [heap]? keep [heap]? mimalloc doesn't use brk + if (b - a < 1 * 1024 * 1024) continue; + candidates.push_back({ (uintptr_t)a, (size_t)(b - a) }); + } + if (maps) fclose(maps); + } +#endif + uintptr_t sp = (uintptr_t)__builtin_frame_address(0); + char path[256]; + snprintf(path, sizeof path, "%s/bun-heapsnap.%d", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp", getpid()); + int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { + fprintf(stderr, "[filesnap] open failed %d\n", errno); + return; + } + unlink(path); + size_t fileOff = 0, remapped = 0, runs = 0, skipped = 0; + std::vector vec; + for (auto& r : candidates) { + if (sp >= r.start && sp < r.start + r.len) { + skipped++; + continue; + } // our own stack + size_t npages = r.len / pg; + vec.assign(npages, 0); +#if OS(DARWIN) + if (mincore((void*)r.start, r.len, (char*)vec.data()) != 0) { + skipped++; + continue; + } +#else + if (mincore((void*)r.start, r.len, vec.data()) != 0) { + skipped++; + continue; + } +#endif + auto want = [&](size_t k) { if (!(vec[k] & 1)) return false; if (!onlyLive) return true; uintptr_t a = r.start + k * pg; return pageIn(s_cellPages, a) || pageIn(s_payloadPages, a); }; + size_t i = 0; + while (i < npages) { + if (!want(i)) { + i++; + continue; + } + size_t j = i; + while (j < npages && want(j)) + j++; + uintptr_t a = r.start + i * pg; + size_t len = (j - i) * pg; + // write pages to file at page-aligned offset, then map that file range back over the same addresses + if (getenv("BUN_FILESNAP_NOREMAP")) { + i = j; + continue; + } + if (pwrite(fd, (void*)a, len, fileOff) != (ssize_t)len) { + fprintf(stderr, "[filesnap] pwrite failed %d\n", errno); + close(fd); + return; + } + void* m = immap((void*)a, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, fd, fileOff); + if (m == MAP_FAILED) { + fprintf(stderr, "[filesnap] mmap fixed failed at %p len %zu errno %d\n", (void*)a, len, errno); + skipped++; + } else { + remapped += len; + runs++; + frozenRanges.push_back({ a, a + len }); + snapshotRuns.push_back({ a, len, fileOff }); + } + fileOff += len; + i = j; + } + } + // MarkedBlocks living outside mimalloc regions (e.g. the StructureHeap reservation, JSC-tagged VM) were skipped above; remap them too. + if (!getenv("BUN_FILESNAP_NOREMAP")) { + std::sort(frozenRanges.begin(), frozenRanges.end()); + std::vector extra; + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + uintptr_t a = (uintptr_t)&h->block(); + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(a, UINTPTR_MAX)); + bool covered = it != frozenRanges.begin() && a < std::prev(it)->second; + if (!covered) extra.push_back(a); + }); + std::sort(extra.begin(), extra.end()); + size_t i = 0, extraBytes = 0; + while (i < extra.size()) { + size_t j = i + 1; + while (j < extra.size() && extra[j] == extra[j - 1] + JSC::MarkedBlock::blockSize) + j++; + uintptr_t a = extra[i]; + size_t len = (j - i) * JSC::MarkedBlock::blockSize; + if (pwrite(fd, (void*)a, len, fileOff) == (ssize_t)len) { + void* m = immap((void*)a, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_FIXED, fd, fileOff); + if (m != MAP_FAILED) { + remapped += len; + runs++; + extraBytes += len; + frozenRanges.push_back({ a, a + len }); + snapshotRuns.push_back({ a, len, fileOff }); + } + fileOff += len; + } + i = j; + } + fprintf(stderr, "[filesnap] additionally remapped %.1fMB of MarkedBlocks outside malloc regions\n", extraBytes / 1048576.0); + } + if (freeze && !getenv("BUN_FILESNAP_NOMI")) { + std::sort(frozenRanges.begin(), frozenRanges.end()); + mi_arenas_freeze_pages(); + size_t inFrozen = 0; + for (int k = 0; k < 64; k++) { + void* probe = mi_malloc(48 + k * 16); + uintptr_t a = (uintptr_t)probe; + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(a, UINTPTR_MAX)); + if (it != frozenRanges.begin() && a < std::prev(it)->second) inFrozen++; + mi_free(probe); + } + void* probe2 = WTF::fastMalloc(100); + uintptr_t a2 = (uintptr_t)probe2; + auto it2 = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(a2, UINTPTR_MAX)); + bool f2 = it2 != frozenRanges.begin() && a2 < std::prev(it2)->second; + WTF::fastFree(probe2); + fprintf(stderr, "[filesnap] post-switch probes landing in frozen ranges: mi_malloc %zu/64, fastMalloc %d\n", inFrozen, (int)f2); + } + std::sort(snapshotRuns.begin(), snapshotRuns.end(), [](const FrozenRun& x, const FrozenRun& y) { return x.start < y.start; }); + std::sort(frozenRanges.begin(), frozenRanges.end()); + if (snapshotFd >= 0 && snapshotFd != fd) + close(snapshotFd); // a previous filesnap's; its mappings stay valid without the descriptor + snapshotFd = fd; + if (const char* prot = getenv("BUN_FILESNAP_PROTECT")) { + // Debug: make snapshot blocks of one subspace read-only so the first writer faults with a backtrace. + size_t n = 0; + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + if (!h->block().isImmortal() || strcmp(h->subspace()->name(), prot)) return; + if (!mprotect(&h->block(), JSC::MarkedBlock::blockSize, PROT_READ)) n++; + }); + fprintf(stderr, "[filesnap] mprotect(PROT_READ) %zu blocks of %s\n", n, prot); + } + // keep fd open for the life of the process (mapping holds a reference anyway) + fprintf(stderr, "[filesnap] candidates=%zu remapped=%.1fMB in %zu runs, skipped=%zu, file=%.1fMB\n", candidates.size(), remapped / 1048576.0, runs, skipped, fileOff / 1048576.0); +} + +static void dumpDirtyMap(JSC::VM& vm) +{ +#if OS(DARWIN) + JSC::JSLockHolder lock(vm); + size_t pg = getpagesize(); + std::map> bySubspace; // name -> (dirtyPages, totalPages) + size_t totalPages = 0, dirtyPages = 0, blockPages = 0, blockDirty = 0, otherDirty = 0; + // index MarkedBlocks by address + std::map blocks; + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + blocks[(uintptr_t)&h->block()] = std::string(h->subspace()->name()) + (h->block().isImmortal() ? "" : " [mortal]"); + }); + std::vector disp; + for (auto& r : frozenRanges) { + size_t n = (r.second - r.first) / pg; + disp.assign(n, 0); + mach_vm_size_t cnt = n; + if (mach_vm_page_range_query(mach_task_self(), r.first, r.second - r.first, (mach_vm_address_t)disp.data(), &cnt) != KERN_SUCCESS) + continue; + for (size_t i = 0; i < n; i++) { + uintptr_t a = r.first + i * pg; + bool dirty = (disp[i] & VM_PAGE_QUERY_PAGE_DIRTY) || (disp[i] & VM_PAGE_QUERY_PAGE_COPIED); + totalPages++; + if (dirty) dirtyPages++; + auto it = blocks.upper_bound(a); + std::string key = ""; + if (it != blocks.begin()) { + --it; + if (a >= it->first && a < it->first + JSC::MarkedBlock::blockSize) key = it->second; + } + if (key == "" && pageIn(s_payloadPages, a)) { + auto sc = s_pageSizeClass.find(a); + size_t bs = sc == s_pageSizeClass.end() ? 0 : sc->second; + const char* bucket = bs <= 16 ? "<=16" : bs <= 32 ? "<=32" + : bs <= 48 ? "<=48" + : bs <= 64 ? "<=64" + : bs <= 96 ? "<=96" + : bs <= 128 ? "<=128" + : bs <= 256 ? "<=256" + : bs <= 512 ? "<=512" + : bs <= 1024 ? "<=1K" + : bs <= 4096 ? "<=4K" + : bs <= 16384 ? "<=16K" + : bs <= 65536 ? "<=64K" + : ">64K"; + key = std::string(""; + } + if (key[0] != '<') { + blockPages++; + if (dirty) blockDirty++; + } else if (dirty) + otherDirty++; + auto& e = bySubspace[key]; + e.second++; + if (dirty) e.first++; + } + } + fprintf(stderr, "[dirtymap] frozen=%.1fMB dirty=%.1fMB | markedBlockPages=%.1fMB dirty=%.1fMB | other=%.1fMB dirty=%.1fMB\n", + totalPages * pg / 1048576.0, dirtyPages * pg / 1048576.0, blockPages * pg / 1048576.0, blockDirty * pg / 1048576.0, (totalPages - blockPages) * pg / 1048576.0, otherDirty * pg / 1048576.0); + std::vector> rows; + for (auto& [k, v] : bySubspace) { + char line[256]; + snprintf(line, sizeof line, " %-40s dirty %7.2fMB / %7.2fMB (%3.0f%%)", k.c_str(), v.first * pg / 1048576.0, v.second * pg / 1048576.0, v.second ? 100.0 * v.first / v.second : 0.0); + rows.push_back({ v.first, line }); + } + std::sort(rows.begin(), rows.end(), std::greater<>()); + for (size_t i = 0; i < std::min(rows.size(), 40); i++) + fprintf(stderr, "%s\n", rows[i].second.c_str()); + + // Byte-level diff of dirty malloc-payload pages against the snapshot file: which blocks changed, and how. + if (snapshotFd >= 0 && !s_liveBlocks.empty()) { + std::vector orig(pg); + size_t changedBytes = 0, dirtyPayloadPages = 0, pagesNoChange = 0; + std::map blockClass; // classification -> count + std::map> bySize; // block size -> (changedBlocks, changedBytes) + std::set changedBlocks; + for (auto& run : snapshotRuns) { + size_t n = run.len / pg; + disp.assign(n, 0); + mach_vm_size_t cnt = n; + if (mach_vm_page_range_query(mach_task_self(), run.start, run.len, (mach_vm_address_t)disp.data(), &cnt) != KERN_SUCCESS) continue; + for (size_t i = 0; i < n; i++) { + uintptr_t a = run.start + i * pg; + bool dirty = (disp[i] & VM_PAGE_QUERY_PAGE_DIRTY) || (disp[i] & VM_PAGE_QUERY_PAGE_COPIED); + if (!dirty || !pageIn(s_payloadPages, a)) continue; + dirtyPayloadPages++; + if (ipread(snapshotFd, orig.data(), pg, run.fileOff + i * pg) != (ssize_t)pg) continue; + const uint8_t* cur = reinterpret_cast(a); + bool any = false; + for (size_t off = 0; off < pg; off += 8) { + if (!memcmp(cur + off, orig.data() + off, 8)) continue; + any = true; + changedBytes += 8; + // find owning block + auto it = std::upper_bound(s_liveBlocks.begin(), s_liveBlocks.end(), std::make_pair(a + off, SIZE_MAX)); + if (it == s_liveBlocks.begin()) { + blockClass[""]++; + continue; + } + --it; + if (a + off >= it->first + it->second) { + blockClass[""]++; + continue; + } + if (changedBlocks.insert(it->first).second) { + bySize[it->second].first++; + } + bySize[it->second].second += 8; + } + if (!any) pagesNoChange++; + } + } + // classify changed blocks by change shape + size_t onlyHeader8 = 0, small32 = 0, larger = 0; + struct SigInfo { + size_t count = 0; + std::vector examples; + }; + std::map smallSigs; + for (uintptr_t b : changedBlocks) { + auto it = std::lower_bound(s_liveBlocks.begin(), s_liveBlocks.end(), std::make_pair(b, (size_t)0)); + size_t sz = it->second; + // re-diff this block + size_t first = SIZE_MAX, cntw = 0; + for (size_t off = 0; off + 8 <= sz; off += 8) { + uintptr_t a = b + off; + uintptr_t page = a & ~(pg - 1); + // find file offset for page + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), page, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r == snapshotRuns.begin()) continue; + --r; + if (page >= r->start + r->len) continue; + uint64_t o; + if (ipread(snapshotFd, &o, 8, r->fileOff + (a - r->start)) != 8) continue; + if (memcmp(&o, (void*)a, 8)) { + cntw++; + if (first == SIZE_MAX) first = off; + } + } + if (cntw == 1 && first == 0) + onlyHeader8++; + else if (cntw <= 4) + small32++; + else + larger++; + if (cntw <= 4 && first != SIZE_MAX) { + // signature: size class, first changed offset, before>after of that word + uintptr_t a = b + first; + uintptr_t page = a & ~(pg - 1); + uint64_t before = 0, after = *(uint64_t*)a; + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), page, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r != snapshotRuns.begin()) { + --r; + if (page < r->start + r->len) ipread(snapshotFd, &before, 8, r->fileOff + (a - r->start)); + } + char sig[160]; + snprintf(sig, sizeof sig, "sz%zu +%zu n%zu", sz, first, cntw); + auto& sc = smallSigs[sig]; + sc.count++; + if (sc.examples.size() < 3) { + char ex[64]; + snprintf(ex, sizeof ex, "%llx>%llx", (unsigned long long)before, (unsigned long long)after); + sc.examples.push_back(ex); + } + } + } + { + std::vector> ss; + for (auto& [k, v] : smallSigs) { + std::string e = k + " x" + std::to_string(v.count) + " ["; + for (auto& x : v.examples) + e += x + " "; + e += "]"; + ss.push_back({ v.count, e }); + } + std::sort(ss.begin(), ss.end(), std::greater<>()); + fprintf(stderr, "[diffmap] small-change signatures (sizeclass +firstOff nWords xCount [before>after...]):\n"); + for (size_t i = 0; i < std::min(ss.size(), 40); i++) + fprintf(stderr, " %s\n", ss[i].second.c_str()); + } + fprintf(stderr, "[diffmap] dirtyPayloadPages=%zu (%.1fMB) pagesWithNoByteChange=%zu changedBytes=%.2fMB changedBlocks=%zu: firstWordOnly=%zu (refcount-like) small(<=4 words)=%zu larger=%zu; strayWrites(outside live blocks)=%zu\n", + dirtyPayloadPages, dirtyPayloadPages * pg / 1048576.0, pagesNoChange, changedBytes / 1048576.0, changedBlocks.size(), onlyHeader8, small32, larger, blockClass[""]); + std::vector> sizes; + for (auto& [sz, v] : bySize) + sizes.push_back({ v.first, sz }); + std::sort(sizes.begin(), sizes.end(), std::greater<>()); + // Cell-granularity diff over immortal MarkedBlocks: how many cells actually changed vs pages dirtied. + { + size_t cellsTotal = 0, cellsChanged = 0, cellsHeaderOnly = 0, bytesInChangedCells = 0, dirtyCellPages = 0, identicalDirtyCellPages = 0; + size_t coldMissCells = 0, coldMissBytes = 0; + std::set coldMissPages; + std::map coldByClass; + std::map> offsetHistBy; + std::map identicalByClass; + std::map> headerPatBy; // high 32 bits of header (indexingType,type,flags,cellState) before>after + std::map> byClass; // class -> (changed, total) + auto fileWordAt = [&](uintptr_t a, uint64_t& out) -> bool { + uintptr_t page = a & ~(pg - 1); + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), page, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r == snapshotRuns.begin()) return false; + --r; + if (page >= r->start + r->len) return false; + return ipread(snapshotFd, &out, 8, r->fileOff + (a - r->start)) == 8; + }; + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + if (!h->block().isImmortal()) return; + // is any page of this block dirty? + uintptr_t base = (uintptr_t)&h->block(); + disp.assign(JSC::MarkedBlock::blockSize / pg, 0); + mach_vm_size_t cnt = disp.size(); + if (mach_vm_page_range_query(mach_task_self(), base, JSC::MarkedBlock::blockSize, (mach_vm_address_t)disp.data(), &cnt) != KERN_SUCCESS) return; + bool anyDirty = false; + for (auto d : disp) + if ((d & VM_PAGE_QUERY_PAGE_DIRTY) || (d & VM_PAGE_QUERY_PAGE_COPIED)) { + anyDirty = true; + dirtyCellPages++; + } + std::string cls = std::string(h->subspace()->name()); + bool blockAnyChange = false; + h->forEachCell([&](size_t, JSC::HeapCell* cell, JSC::HeapCell::Kind) -> IterationStatus { + if (!h->block().isMarkedRaw(cell)) return IterationStatus::Continue; + cellsTotal++; + byClass[cls].second++; + if (!anyDirty) return IterationStatus::Continue; + size_t changedWords = 0; + bool headerChanged = false; + static const char* offCls = getenv("BUN_MEMDEBUG_OFFSETS_FOR"); + bool trackOff = offCls && (std::string(",") + offCls + ",").find("," + cls + ",") != std::string::npos; + for (size_t off = 0; off + 8 <= h->cellSize(); off += 8) { + uint64_t o; + if (!fileWordAt((uintptr_t)cell + off, o)) break; + if (memcmp(&o, (uint8_t*)cell + off, 8)) { + changedWords++; + if (!off) { + headerChanged = true; + if (trackOff) { + uint64_t cur; + memcpy(&cur, (uint8_t*)cell, 8); + char buf[64]; + snprintf(buf, sizeof buf, "%016llx>%016llx", (unsigned long long)(o & 0xffffffff00000000ull), (unsigned long long)(cur & 0xffffffff00000000ull)); + headerPatBy[cls][buf]++; + } + } + if (trackOff) offsetHistBy[cls][off]++; + } + } + if (changedWords) { + cellsChanged++; + byClass[cls].first++; + bytesInChangedCells += h->cellSize(); + blockAnyChange = true; + if (changedWords == 1 && headerChanged) cellsHeaderOnly++; + if (s_recordProfile) + s_profileCells.insert((uintptr_t)cell); + else if (!s_profileCells.empty() && !s_profileCells.count((uintptr_t)cell)) { + coldMissCells++; + coldMissBytes += h->cellSize(); + coldMissPages.insert((uintptr_t)cell & ~(pg - 1)); + coldByClass[cls]++; + } + } + return IterationStatus::Continue; + }); + if (anyDirty && !blockAnyChange) { + size_t nd = 0; + for (auto d : disp) + if ((d & VM_PAGE_QUERY_PAGE_DIRTY) || (d & VM_PAGE_QUERY_PAGE_COPIED)) nd++; + identicalDirtyCellPages += nd; + identicalByClass[cls] += nd; + } + }); + fprintf(stderr, "[celldiff] immortal live cells=%zu changed=%zu (%.1f%%) headerOnly=%zu bytesOfChangedCells=%.2fMB vs dirtyCellPages=%.2fMB (identical-content dirty pages=%.2fMB) => perfect segregation would dirty ~%.2fMB\n", + cellsTotal, cellsChanged, cellsTotal ? 100.0 * cellsChanged / cellsTotal : 0.0, cellsHeaderOnly, bytesInChangedCells / 1048576.0, dirtyCellPages * pg / 1048576.0, identicalDirtyCellPages * pg / 1048576.0, bytesInChangedCells / 1048576.0); + if (s_recordProfile) + fprintf(stderr, "[cellprofile] recorded %zu changed cells as the hot profile\n", s_profileCells.size()); + else if (!s_profileCells.empty()) { + fprintf(stderr, "[celldiff] vs profile(%zu hot cells): cells changed that were NOT hot in profile = %zu (%.2fMB of cells, spanning %zu distinct 16K pages = %.2fMB upper bound)\n", s_profileCells.size(), coldMissCells, coldMissBytes / 1048576.0, coldMissPages.size(), coldMissPages.size() * pg / 1048576.0); + std::vector> cm; + for (auto& [k, v] : coldByClass) + cm.push_back({ v, k }); + std::sort(cm.begin(), cm.end(), std::greater<>()); + fprintf(stderr, " cold misses by class:"); + for (size_t i = 0; i < std::min(cm.size(), 10); i++) + fprintf(stderr, " %s=%zu", cm[i].second.c_str(), cm[i].first); + fprintf(stderr, "\n"); + } + { + std::vector> ib; + for (auto& [k, v] : identicalByClass) + ib.push_back({ v, k }); + std::sort(ib.begin(), ib.end(), std::greater<>()); + fprintf(stderr, "[celldiff] identical-content dirty pages by class:"); + for (size_t i = 0; i < std::min(ib.size(), 10); i++) + fprintf(stderr, " %s=%.2fMB", ib[i].second.c_str(), ib[i].first * pg / 1048576.0); + fprintf(stderr, "\n"); + } + for (auto& [c, hist] : offsetHistBy) { + fprintf(stderr, "[celldiff] changed word offsets for %s:", c.c_str()); + for (auto& [off, n] : hist) + fprintf(stderr, " +%zu:%zu", off, n); + fprintf(stderr, "\n"); + } + for (auto& [c, pats] : headerPatBy) { + fprintf(stderr, "[celldiff] header byte patterns (idxType,type,flags,cellState hi32 before>after) for %s:", c.c_str()); + size_t k = 0; + for (auto& [pat, n] : pats) { + if (k++ < 8) fprintf(stderr, " %s x%zu", pat.c_str(), n); + } + fprintf(stderr, "\n"); + } + std::vector> crow; + for (auto& [k, v] : byClass) { + char line[200]; + snprintf(line, sizeof line, " %-36s changed %7zu / %7zu (%3.0f%%)", k.c_str(), v.first, v.second, v.second ? 100.0 * v.first / v.second : 0.0); + crow.push_back({ v.first, line }); + } + std::sort(crow.begin(), crow.end(), std::greater<>()); + for (size_t i = 0; i < std::min(crow.size(), 18); i++) + fprintf(stderr, "%s\n", crow[i].second.c_str()); + } + // Fast path: stacks for just the changed blocks (one pass over samples to index by address; no file reads). + if (getenv("MIMALLOC_PROF_SAMPLE_RATE")) { + // No allocation while the profiler lock is held (a sampled malloc under it self-deadlocks): count, preallocate, then copy PODs. + struct Rec { + uintptr_t addr; + uint8_t n; + uintptr_t frames[14]; + }; + struct Raw { + Rec* recs; + size_t cap, n; + }; + size_t liveCount = 0; + mi_prof_visit_live([](uintptr_t, size_t, const uintptr_t*, uint8_t, void* arg) -> bool { ++*static_cast(arg); return true; }, &liveCount); + Raw raw { (Rec*)mmap(nullptr, (liveCount + 1024) * sizeof(Rec), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0), liveCount + 1024, 0 }; + if (raw.recs == MAP_FAILED) + fprintf(stderr, "[owners] could not allocate the record buffer; report skipped\n"); + else { + mi_prof_visit_live([](uintptr_t addr, size_t, const uintptr_t* frames, uint8_t nframes, void* arg) -> bool { + Raw* r = static_cast(arg); + if (r->n >= r->cap) return false; + Rec& rec = r->recs[r->n++]; + rec.addr = addr; + rec.n = std::min(nframes, 14); + memcpy(rec.frames, frames, rec.n * sizeof(uintptr_t)); + return true; + }, + &raw); + struct Ix { + std::unordered_map byAddr; + } ix; + ix.byAddr.reserve(raw.n); + for (size_t i = 0; i < raw.n; i++) + ix.byAddr.emplace(raw.recs[i].addr, &raw.recs[i]); + char path2[512]; + snprintf(path2, sizeof path2, "%s/changed-owners.%d.tsv", s_dir, getpid()); + if (FILE* f2 = fopen(path2, "w")) { + size_t hit = 0; + for (uintptr_t b : changedBlocks) { + auto it = std::lower_bound(s_liveBlocks.begin(), s_liveBlocks.end(), std::make_pair(b, (size_t)0)); + size_t sz = (it != s_liveBlocks.end() && it->first == b) ? it->second : 0; + auto s = ix.byAddr.find(b); + if (s == ix.byAddr.end()) continue; + hit++; + fprintf(f2, "%zu\t1\t0\t", sz); + for (size_t k = 0; k < s->second->n; k++) + fprintf(f2, "%s0x%lx", k ? ";" : "", (unsigned long)s->second->frames[k]); + fprintf(f2, "\n"); + } + fclose(f2); + fprintf(stderr, "[owners-fast] %zu of %zu changed blocks had samples -> %s\n", hit, changedBlocks.size(), path2); + } + munmap(raw.recs, raw.cap * sizeof(Rec)); + } + } + // Owners of mutated payload: join live profiler samples with the byte diff. + if (getenv("MIMALLOC_PROF_SAMPLE_RATE") && getenv("BUN_MEMDEBUG_SLOW_OWNERS")) { + struct Ctx { + std::function* fileWordAt; + FILE* f; + size_t n; + size_t changed; + }; + std::function fw = [&](uintptr_t a, uint64_t& out) -> bool { + uintptr_t page = a & ~(pg - 1); + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), page, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r == snapshotRuns.begin()) return false; + --r; + if (page >= r->start + r->len) return false; + return ipread(snapshotFd, &out, 8, r->fileOff + (a - r->start)) == 8; + }; + char path[512]; + snprintf(path, sizeof path, "%s/payload-owners.%d.tsv", s_dir, getpid()); + Ctx ctx { &fw, fopen(path, "w"), 0, 0 }; + static char obuf[1 << 20]; + if (ctx.f) setvbuf(ctx.f, obuf, _IOFBF, sizeof obuf); // no malloc under the profiler lock (sampled malloc would self-deadlock) + if (ctx.f) { + mi_prof_visit_live([](uintptr_t addr, size_t size, const uintptr_t* frames, uint8_t nframes, void* arg) -> bool { + Ctx* c = static_cast(arg); + // only blocks inside the frozen snapshot + uint64_t probe; + if (!(*c->fileWordAt)(addr, probe)) return true; + size_t changedWords = 0, firstOff = SIZE_MAX; + static uint8_t fbuf[1 << 16]; + for (size_t base = 0; base < size; base += sizeof fbuf) { + size_t n = std::min(sizeof fbuf, size - base); + uintptr_t a0 = addr + base; + uintptr_t page = a0 & ~(uintptr_t)(getpagesize() - 1); + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), page, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r == snapshotRuns.begin()) break; + --r; + if (a0 >= r->start + r->len) break; + n = std::min(n, r->start + r->len - a0); + if (ipread(snapshotFd, fbuf, n, r->fileOff + (a0 - r->start)) != (ssize_t)n) break; + for (size_t off = 0; off + 8 <= n; off += 8) + if (memcmp(fbuf + off, (void*)(a0 + off), 8)) { + changedWords++; + if (firstOff == SIZE_MAX) firstOff = base + off; + } + } + c->n++; + if (changedWords) c->changed++; + fprintf(c->f, "%zu\t%zu\t%zu\t", size, changedWords, firstOff == SIZE_MAX ? 0 : firstOff); + for (uint8_t k = 0; k < nframes && k < 14; k++) + fprintf(c->f, "%s0x%lx", k ? ";" : "", (unsigned long)frames[k]); + fprintf(c->f, "\n"); + return true; + }, + &ctx); + fclose(ctx.f); + fprintf(stderr, "[owners] wrote %s: %zu live sampled snapshot blocks, %zu changed; loadaddr=%p\n", path, ctx.n, ctx.changed, (void*)_dyld_get_image_header(0)); + } + } + fprintf(stderr, "[diffmap] changed blocks by block size (count, bytes changed):"); + for (size_t i = 0; i < std::min(sizes.size(), 24); i++) + fprintf(stderr, " %zu:%zu/%zuB", sizes[i].second, sizes[i].first, bySize[sizes[i].second].second); + fprintf(stderr, "\n"); + } +#endif +} + +static void dumpUCBCensus(JSC::VM& vm) +{ + JSC::JSLockHolder lock(vm); + struct Acc { + size_t n = 0, cell = 0, ins = 0, expr = 0, meta = 0, ident = 0, cst = 0, jt = 0, prof = 0, rare = 0; + } all, fresh; + { + JSC::HeapIterationScope scope(vm.heap); + vm.heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* cell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) return IterationStatus::Continue; + auto* ucb = dynamicDowncast(static_cast(cell)); + if (!ucb) return IterationStatus::Continue; + auto c = ucb->componentSizesForCensus(); + bool isNew = cell->isPreciseAllocation() ? !cell->preciseAllocation().isImmortal() : !cell->markedBlock().isImmortal(); + for (Acc* a : { &all, isNew ? &fresh : (Acc*)nullptr }) { + if (!a) continue; + a->n++; + a->cell += cell->cellSize(); + a->ins += c.instructions; + a->expr += c.expressionInfo; + a->meta += c.metadata; + a->ident += c.identifiers; + a->cst += c.constants; + a->jt += c.jumpTargets; + a->prof += c.profiles; + a->rare += c.rareData; + } + return IterationStatus::Continue; + }); + } + for (auto [name, a] : { std::pair { "all", all }, std::pair { "new", fresh } }) { + double M = 1048576.0; + size_t tot = a.cell + a.ins + a.expr + a.meta + a.ident + a.cst + a.jt + a.prof + a.rare; + fprintf(stderr, "[ucbcensus] %s: %zu UnlinkedCodeBlocks total=%.2fMB | cell=%.2f instructions=%.2f expressionInfo=%.2f unlinkedMetadata=%.2f identifiers=%.2f constants=%.2f jumpTargets=%.2f profiles=%.2f rareData=%.2f (MB)\n", name, a.n, tot / M, a.cell / M, a.ins / M, a.expr / M, a.meta / M, a.ident / M, a.cst / M, a.jt / M, a.prof / M, a.rare / M); + } + // Linked CodeBlocks: cell + MetadataTable + JIT code by tier + { + size_t n = 0, cellB = 0, metaB = 0, jitB[8] = { 0 }, jitN[8] = { 0 }; + JSC::HeapIterationScope scope2(vm.heap); + vm.heap.objectSpace().forEachLiveCell(scope2, [&](JSC::HeapCell* cell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) return IterationStatus::Continue; + auto* cb = dynamicDowncast(static_cast(cell)); + if (!cb) return IterationStatus::Continue; + n++; + cellB += cell->cellSize(); + if (auto* mt = cb->metadataTable()) metaB += mt->sizeInBytesForGC(); + if (auto jit = cb->jitCode()) { + unsigned t = std::min(7, static_cast(jit->jitType())); + jitN[t]++; + jitB[t] += jit->size(); + } + return IterationStatus::Continue; + }); + double M = 1048576.0; + fprintf(stderr, "[cbcensus] %zu CodeBlocks: cell=%.2fMB metadataTables=%.2fMB | jit code by JITType index:", n, cellB / M, metaB / M); + for (int t = 0; t < 8; t++) + if (jitN[t]) fprintf(stderr, " [%d]=%zux/%.2fMB", t, jitN[t], jitB[t] / M); + fprintf(stderr, "\n"); + } +} + +static void dumpNewPayload(JSC::VM& vm) +{ + JSC::JSLockHolder lock(vm); + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + mi_collect(true); + char path[512]; + snprintf(path, sizeof path, "%s/new-payload.%d.tsv", s_dir ? s_dir : "/tmp", getpid()); + struct Ctx { + FILE* f; + size_t n, bytes; + }; + Ctx ctx { fopen(path, "w"), 0, 0 }; + if (!ctx.f) return; + static char obuf[1 << 20]; + setvbuf(ctx.f, obuf, _IOFBF, sizeof obuf); + mi_prof_visit_live([](uintptr_t addr, size_t size, const uintptr_t* frames, uint8_t nframes, void* arg) -> bool { + Ctx* c = static_cast(arg); + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(addr, UINTPTR_MAX)); + if (it != frozenRanges.begin() && addr < std::prev(it)->second) return true; // snapshot block + c->n++; + c->bytes += size; + fprintf(c->f, "%zu\t1\t0\t", size); // same columns as payload-owners.tsv (size, changedWords, firstOff, frames) + for (uint8_t k = 0; k < nframes && k < 14; k++) + fprintf(c->f, "%s0x%lx", k ? ";" : "", (unsigned long)frames[k]); + fprintf(c->f, "\n"); + return true; + }, + &ctx); + fclose(ctx.f); + char rateText[32]; + snprintf(rateText, sizeof rateText, "%zu", s_profSampleRate ? s_profSampleRate : (getenv("MIMALLOC_PROF_SAMPLE_RATE") ? (size_t)strtoull(getenv("MIMALLOC_PROF_SAMPLE_RATE"), nullptr, 10) : 0)); + fprintf(stderr, "[newpayload] %zu live sampled post-restore blocks, %.1fMB (each ~%s bytes of allocation volume) -> %s\n", ctx.n, ctx.bytes / 1048576.0, rateText, path); +} + +static void dumpNewCells(JSC::VM& vm) +{ + JSC::JSLockHolder lock(vm); + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + struct E { + size_t n = 0, bytes = 0; + }; + std::map byClass; + size_t total = 0, totalBytes = 0, mortalBlocks = 0, mortalBlockLive = 0; + struct D { + size_t blocks = 0, liveBytes = 0, capBytes = 0, emptyBlocks = 0; + }; + std::map byDir; + JSC::HeapIterationScope scope(vm.heap); + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + if (h->block().isImmortal()) return; + mortalBlocks++; + size_t live = 0; + h->forEachLiveCell([&](size_t, JSC::HeapCell*, JSC::HeapCell::Kind) { live++; return IterationStatus::Continue; }); + char key[96]; + snprintf(key, sizeof key, "%s/%zu", h->subspace()->name(), h->cellSize()); + auto& d = byDir[key]; + d.blocks++; + d.liveBytes += live * h->cellSize(); + d.capBytes += h->cellsPerBlock() * h->cellSize(); + if (!live) d.emptyBlocks++; + }); + vm.heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* cell, JSC::HeapCell::Kind kind) { + bool isNew = cell->isPreciseAllocation() ? !cell->preciseAllocation().isImmortal() : !cell->markedBlock().isImmortal(); + if (!isNew) return IterationStatus::Continue; + size_t sz = cell->cellSize(); + std::string name = isJSCellKind(kind) ? std::string(static_cast(cell)->className()) : std::string("(aux) ") + (cell->isPreciseAllocation() ? "precise" : cell->markedBlock().handle().subspace()->name()); + auto& e = byClass[name]; + e.n++; + e.bytes += sz; + total++; + totalBytes += sz; + if (!cell->isPreciseAllocation()) mortalBlockLive += sz; + return IterationStatus::Continue; + }); + { + std::vector> rows; + for (auto& [k, d] : byDir) { + char line[200]; + snprintf(line, sizeof line, " %-40s blocks=%4zu (%5.2fMB) live=%5.2fMB occupancy=%3.0f%% empty=%zu", k.c_str(), d.blocks, d.blocks * JSC::MarkedBlock::blockSize / 1048576.0, d.liveBytes / 1048576.0, d.capBytes ? 100.0 * d.liveBytes / d.capBytes : 0.0, d.emptyBlocks); + rows.push_back({ d.blocks, line }); + } + std::sort(rows.begin(), rows.end(), std::greater<>()); + fprintf(stderr, "[newcells] mortal blocks by directory (subspace/cellSize):\n"); + for (size_t i = 0; i < std::min(rows.size(), 25); i++) + fprintf(stderr, "%s\n", rows[i].second.c_str()); + } + fprintf(stderr, "[newcells] after full GC: %zu new cells, %.2fMB cell bytes; %zu mortal MarkedBlocks = %.2fMB (%.0f%% live)\n", total, totalBytes / 1048576.0, mortalBlocks, mortalBlocks * JSC::MarkedBlock::blockSize / 1048576.0, mortalBlocks ? 100.0 * mortalBlockLive / (mortalBlocks * JSC::MarkedBlock::blockSize) : 0.0); + std::vector> rows; + for (auto& [k, e] : byClass) { + char line[200]; + snprintf(line, sizeof line, " %-44s %8zu %8.2fMB", k.c_str(), e.n, e.bytes / 1048576.0); + rows.push_back({ e.bytes, line }); + } + std::sort(rows.begin(), rows.end(), std::greater<>()); + for (size_t i = 0; i < std::min(rows.size(), 30); i++) + fprintf(stderr, "%s\n", rows[i].second.c_str()); +} + +static void dumpMutatedSnapshotObjects(JSC::VM& vm) +{ + JSC::JSLockHolder lock(vm); + if (snapshotFd < 0 || snapshotRuns.empty()) { + fprintf(stderr, "[mutated] no snapshot\n"); + return; + } + size_t pg = getpagesize(); + auto fileBytesAt = [&](uintptr_t a, void* out, size_t n) -> bool { + auto r = std::upper_bound(snapshotRuns.begin(), snapshotRuns.end(), a, [](uintptr_t v, const FrozenRun& fr) { return v < fr.start; }); + if (r == snapshotRuns.begin()) return false; + --r; + if (a + n > r->start + r->len) return false; + return ipread(snapshotFd, out, n, r->fileOff + (a - r->start)) == (ssize_t)n; + }; + struct Agg { + size_t objects = 0, headerChanged = 0, butterflyPtrChanged = 0, inlineChanged = 0, butterflyContentsChanged = 0; + }; + std::map byShape; + size_t scanned = 0, changed = 0; + std::vector orig(16384), origBf(16384); // a MarkedBlock cell can be up to ~8 KB (global objects are); compare all of it + JSC::HeapIterationScope scope(vm.heap); + vm.heap.objectSpace().forEachLiveCell(scope, [&](JSC::HeapCell* heapCell, JSC::HeapCell::Kind kind) { + if (!isJSCellKind(kind)) return IterationStatus::Continue; + bool immortal = heapCell->isPreciseAllocation() ? heapCell->preciseAllocation().isImmortal() : heapCell->markedBlock().isImmortal(); + if (!immortal) return IterationStatus::Continue; + JSC::JSCell* cell = static_cast(heapCell); + JSC::JSObject* object = dynamicDowncast(cell); + if (!object) return IterationStatus::Continue; + size_t sz = std::min(heapCell->cellSize(), orig.size()); + // quick page-level filter: skip cells on clean pages +#if OS(DARWIN) + { + if (object->butterfly()) // its contents are compared below too, and they live on other pages: no page shortcut for these + goto scanCell; + uintptr_t first = (uintptr_t)cell & ~(pg - 1); + uintptr_t last = ((uintptr_t)cell + heapCell->cellSize() - 1) & ~(pg - 1); + mach_vm_size_t cnt = (last - first) / pg + 1; + static std::vector disp; // reused across cells: this is the fast path + disp.resize(cnt); + bool allClean = mach_vm_page_range_query(mach_task_self(), first, cnt * pg, (mach_vm_address_t)disp.data(), &cnt) == KERN_SUCCESS; + for (mach_vm_size_t k = 0; allClean && k < cnt; k++) + allClean = !(disp[k] & (VM_PAGE_QUERY_PAGE_DIRTY | VM_PAGE_QUERY_PAGE_COPIED)); + if (allClean) { + scanned++; + return IterationStatus::Continue; + } + } +#else + (void)pg; +#endif +#if OS(DARWIN) + scanCell: +#endif + if (!fileBytesAt((uintptr_t)cell, orig.data(), sz)) return IterationStatus::Continue; + scanned++; + bool header = memcmp(orig.data(), cell, 8) != 0; // structureID/indexing/type/flags/cellState + uint64_t oldBf; + memcpy(&oldBf, orig.data() + 8, 8); + bool bfPtr = oldBf != *(uint64_t*)((uint8_t*)cell + 8); + bool inl = sz > 16 && memcmp(orig.data() + 16, (uint8_t*)cell + 16, sz - 16) != 0; + bool bfContents = false; + if (JSC::Butterfly* bf = object->butterfly(); bf && !bfPtr) { // same butterfly: did its out-of-line slots / elements change? + JSC::Structure* st = object->structure(); + size_t oolBytes = st->outOfLineCapacity() * sizeof(JSC::EncodedJSValue); + size_t pre = oolBytes + sizeof(JSC::IndexingHeader); // the slots end one header before the pointer whether or not a header is allocated + size_t post = 0; + if (JSC::hasIndexedProperties(object->indexingType())) { + post = std::min(bf->vectorLength(), 256) * sizeof(JSC::EncodedJSValue); + if (JSC::hasAnyArrayStorage(object->indexingType())) post += JSC::ArrayStorage::vectorOffset(); // elements start after the ArrayStorage header + } + uintptr_t base = (uintptr_t)bf - pre; + size_t indexedBytes = JSC::hasIndexedProperties(object->indexingType()) ? sizeof(JSC::IndexingHeader) + post : 0; // header only exists with indexed storage + size_t n = std::min(oolBytes + indexedBytes, origBf.size()); + if (fileBytesAt(base, origBf.data(), n)) bfContents = memcmp(origBf.data(), (void*)base, n) != 0; + } + if (!(header || bfPtr || inl || bfContents)) return IterationStatus::Continue; + changed++; + std::string shape(cell->className().characters()); + shape += " {"; + { + int k = 0; + JSC::Structure* st = object->structure(); + if (!st->hasPropertyTableForSnapshot()) + shape += "?"; + else + st->forEachProperty(vm, [&](const JSC::PropertyTableEntry& e) { if (k < 5) { if (k) shape += ","; auto* u = e.key(); shape += (u && u->is8Bit()) ? std::string((const char*)u->span8().data(), std::min(u->length(), 24)) : "?"; } k++; return true; }); + if (k > 5) shape += ",+" + std::to_string(k - 5); + } + shape += "}"; + auto& a = byShape[shape]; + a.objects++; + a.headerChanged += header; + a.butterflyPtrChanged += bfPtr; + a.inlineChanged += inl; + a.butterflyContentsChanged += bfContents; + return IterationStatus::Continue; + }); + std::vector> rows; + for (auto& [k, a] : byShape) { + char line[400]; + snprintf(line, sizeof line, " %6zu hdr=%-5zu bfptr=%-5zu inline=%-5zu bfdata=%-5zu %s", a.objects, a.headerChanged, a.butterflyPtrChanged, a.inlineChanged, a.butterflyContentsChanged, k.c_str()); + rows.push_back({ a.objects, line }); + } + std::sort(rows.begin(), rows.end(), std::greater<>()); + fprintf(stderr, "[mutated] %zu snapshotted JS objects changed since restore (of %zu compared). By class {first properties}: count, what changed (cell header / butterfly pointer i.e. regrown / inline slots / butterfly contents)\n", changed, scanned); + for (size_t i = 0; i < std::min(rows.size(), 60); i++) + fprintf(stderr, "%s\n", rows[i].second.c_str()); +} + +struct TrapRec { + uintptr_t page; + uintptr_t pcs[10]; +}; +static TrapRec* s_trapRecs = nullptr; +static std::atomic s_trapCount { 0 }; +static size_t s_trapCap = 0; +static struct sigaction s_prevBus, s_prevSegv; + +static void snapshotTrapHandler(int sig, siginfo_t* info, void* uctx) +{ + uintptr_t a = (uintptr_t)info->si_addr; + size_t pg = getpagesize(); + uintptr_t page = a & ~(pg - 1); + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(a, UINTPTR_MAX)); + bool ours = s_trapCap && it != frozenRanges.begin() && a < std::prev(it)->second; + if (!ours) { +#if OS(DARWIN) && CPU(ARM64) + { // not a snapshot page: real crash. Dump a raw backtrace we can atos, then chain. + ucontext_t* uc = (ucontext_t*)uctx; + char line[96]; + int n = snprintf(line, sizeof line, "[snapshotcrash] sig=%d addr=%lx pc=%llx lr=%llx frames:", sig, (unsigned long)a, (unsigned long long)__darwin_arm_thread_state64_get_pc(uc->uc_mcontext->__ss), (unsigned long long)__darwin_arm_thread_state64_get_lr(uc->uc_mcontext->__ss)); + write(2, line, n); + uintptr_t fp = (uintptr_t)__darwin_arm_thread_state64_get_fp(uc->uc_mcontext->__ss); + for (int k = 0; k < 40 && fp && !(fp & 7); k++) { + uintptr_t* f = (uintptr_t*)fp; + n = snprintf(line, sizeof line, " %lx", (unsigned long)f[1]); + write(2, line, n); + if (f[0] <= fp) break; + fp = f[0]; + } + write(2, "\n", 1); + } +#endif + struct sigaction* prev = sig == SIGBUS ? &s_prevBus : &s_prevSegv; + if (prev->sa_flags & SA_SIGINFO) + prev->sa_sigaction(sig, info, uctx); + else if (prev->sa_handler == SIG_DFL || prev->sa_handler == SIG_IGN) { + signal(sig, SIG_DFL); + raise(sig); + } else + prev->sa_handler(sig); + return; + } + mprotect((void*)page, pg, PROT_READ | PROT_WRITE); + size_t i = s_trapCount.fetch_add(1); + if (i < s_trapCap) { + TrapRec& r = s_trapRecs[i]; + r.page = page; +#if OS(DARWIN) && CPU(ARM64) + ucontext_t* uc = (ucontext_t*)uctx; + r.pcs[0] = (uintptr_t)__darwin_arm_thread_state64_get_pc(uc->uc_mcontext->__ss); + r.pcs[1] = (uintptr_t)__darwin_arm_thread_state64_get_lr(uc->uc_mcontext->__ss); + uintptr_t fp = (uintptr_t)__darwin_arm_thread_state64_get_fp(uc->uc_mcontext->__ss); + for (int k = 2; k < 10; k++) { + if (!fp || (fp & 7)) { + r.pcs[k] = 0; + continue; + } + uintptr_t* f = (uintptr_t*)fp; + r.pcs[k] = f[1]; + uintptr_t next = f[0]; + if (next <= fp) { + fp = 0; + continue; + } + fp = next; + } +#else + (void)uctx; + memset(r.pcs, 0, sizeof r.pcs); +#endif + } +} + +static void snapshotTrapArm() +{ + const size_t pg = getpagesize(); + s_trapCap = 1 << 18; + s_trapRecs = (TrapRec*)mmap(nullptr, s_trapCap * sizeof(TrapRec), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (s_trapRecs == MAP_FAILED) { + s_trapRecs = nullptr; + s_trapCap = 0; // nothing gets protected below, so no faults occur: trapping is off entirely + fprintf(stderr, "[snapshottrap] could not allocate the record buffer; trapping disabled\n"); + return; + } + struct sigaction sa {}; + sa.sa_sigaction = snapshotTrapHandler; + sa.sa_flags = SA_SIGINFO | SA_NODEFER; + sigemptyset(&sa.sa_mask); + sigaction(SIGBUS, &sa, &s_prevBus); + sigaction(SIGSEGV, &sa, &s_prevSegv); + size_t n = 0; + const char* mode = getenv("BUN_STARTUP_SNAPSHOT_TRAP"); + if (mode && !strcmp(mode, "cells")) { // only MarkedBlock pages: syscalls never target them, so kernel-side EFAULTs can't derail the run + for (uintptr_t page : s_cellPages) + if (!mprotect((void*)page, pg, PROT_READ)) n += pg; + } else + for (auto& r : frozenRanges) { + if (!mprotect((void*)r.first, r.second - r.first, PROT_READ)) n += r.second - r.first; + } + fprintf(stderr, "[snapshottrap] armed: %.1fMB read-only (%s)\n", n / 1048576.0, mode); +} + +static void snapshotTrapReport() +{ + size_t n = std::min(s_trapCount.load(), s_trapCap); + char path[512]; + snprintf(path, sizeof path, "%s/snapshottrap.%d.tsv", s_dir ? s_dir : "/tmp", getpid()); + FILE* f = fopen(path, "w"); + if (!f) return; + for (size_t i = 0; i < n; i++) { + TrapRec& r = s_trapRecs[i]; + fprintf(f, "%lx\t%s", (unsigned long)r.page, pageIn(s_cellPages, r.page) ? "cell" : pageIn(s_payloadPages, r.page) ? "payload" + : "other"); + for (int k = 0; k < 10; k++) + fprintf(f, "%c%lx", k ? ';' : '\t', (unsigned long)r.pcs[k]); + fprintf(f, "\n"); + } + fclose(f); + fprintf(stderr, "[snapshottrap] %zu first-write faults recorded (%.1fMB of pages) -> %s\n", n, n * (size_t)getpagesize() / 1048576.0, path); + if (s_trapCount.load() > n) fprintf(stderr, "[snapshottrap] %zu further faults were not recorded (cap of %zu)\n", s_trapCount.load() - n, n); +} + +void startupSnapshotToolingIndexAtFreeze(JSC::VM& vm, size_t pg) +{ + s_cellPages.clear(); + s_payloadPages.clear(); + s_pageSizeClass.clear(); + s_liveBlocks.clear(); + vm.heap.objectSpace().forEachBlock([&](JSC::MarkedBlock::Handle* h) { + for (uintptr_t a = (uintptr_t)&h->block(); a < (uintptr_t)&h->block() + JSC::MarkedBlock::blockSize; a += pg) + s_cellPages.push_back(a); + }); + mi_heap_visit_blocks(mi_heap_main(), true, recordUsedBlock, &pg); + std::sort(s_cellPages.begin(), s_cellPages.end()); + std::sort(s_liveBlocks.begin(), s_liveBlocks.end()); + std::sort(s_payloadPages.begin(), s_payloadPages.end()); + s_payloadPages.erase(std::unique(s_payloadPages.begin(), s_payloadPages.end()), s_payloadPages.end()); + std::vector tmp; + std::set_difference(s_payloadPages.begin(), s_payloadPages.end(), s_cellPages.begin(), s_cellPages.end(), std::back_inserter(tmp)); + s_payloadPages.swap(tmp); + fprintf(stderr, "[snapshot] cellPages=%.1fMB payloadPages=%.1fMB liveMallocBlocks=%zu\n", s_cellPages.size() * pg / 1048576.0, s_payloadPages.size() * pg / 1048576.0, s_liveBlocks.size()); +} + +void startupSnapshotToolingArmTraps() +{ + if (getenv("BUN_STARTUP_SNAPSHOT_TRAP")) + snapshotTrapArm(); + else if (getenv("BUN_STARTUP_SNAPSHOT_CRASHBT")) { + struct sigaction sa {}; + sa.sa_sigaction = snapshotTrapHandler; + sa.sa_flags = SA_SIGINFO | SA_NODEFER; + sigemptyset(&sa.sa_mask); + sigaction(SIGBUS, &sa, &s_prevBus); + sigaction(SIGSEGV, &sa, &s_prevSegv); + } // backtrace-only: frozenRanges stays as-is but nothing is protected +} + +void startupSnapshotToolingAfterRestore() +{ + const char* d = getenv("BUN_MEMDEBUG"); + s_dir = (d && *d) ? strdup(d) : nullptr; // the builder's pointer would point into its environment + if (s_dir) { + s_profSampleRate = 64 * 1024; + mi_prof_enable(s_profSampleRate); // the profiler state came from the builder (off); sample what this process allocates so newpayload can attribute it + } +} + +void startupSnapshotToolingInstall() +{ + s_dir = getenv("BUN_MEMDEBUG"); + if (!s_dir || !*s_dir) { + s_dir = nullptr; + return; + } + signal(SIGUSR1, memdebugSignal); +#ifdef SIGINFO + signal(SIGINFO, memdebugSignal); +#endif + signal(SIGXCPU, memdebugSignal); +} + +static bool onMainThread() +{ +#if OS(DARWIN) + return pthread_main_np() != 0; +#else + return gettid() == getpid(); +#endif +} + +extern "C" void Bun__startupSnapshotToolingTick(JSC::VM* vm) +{ + if (!onMainThread()) // every loop ticks this, workers included; the state below is the main VM's + return; + int req = s_requested.exchange(0); + bool fromCmdFile = false; + if (!s_dir) + return; + if (const char* at = getenv("BUN_STARTUP_SNAPSHOT_OUT_AT_MS")) { + static bool doneImg = false; + static auto startImg = std::chrono::steady_clock::now(); + if (!doneImg && !req && std::chrono::duration_cast(std::chrono::steady_clock::now() - startImg).count() > atoi(at)) { + doneImg = true; + req = 8; + } + } + if (const char* at = getenv("BUN_FILESNAP_AT_MS")) { + static bool done = false; + static auto start = std::chrono::steady_clock::now(); + if (!done && !req && std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count() > atoi(at)) { + done = true; + req = 4; + } + } + if (!req) { + std::string cmdPath = std::string(s_dir) + "/cmd." + std::to_string(getpid()); + FILE* cf = fopen(cmdPath.c_str(), "r"); + if (!cf) + return; + char buf[32] = { 0 }; + fgets(buf, sizeof(buf), cf); + fclose(cf); + unlink(cmdPath.c_str()); + if (!strncmp(buf, "filesnap", 8)) + req = 4; + else if (!strncmp(buf, "dirtymap", 8)) + req = 5; + else if (!strncmp(buf, "reclean", 7)) + req = 6; + else if (!strncmp(buf, "cellprofile", 11)) + req = 7; + else if (!strncmp(buf, "snapshot", 8)) + req = 8; + else if (!strncmp(buf, "trapreport", 10)) + req = 9; + else if (!strncmp(buf, "newcells", 8)) + req = 10; + else if (!strncmp(buf, "newpayload", 10)) + req = 11; + else if (!strncmp(buf, "ucbcensus", 9)) + req = 12; + else if (!strncmp(buf, "mutated", 7)) + req = 13; + else if (!strncmp(buf, "shrink", 6)) + req = 3; + else if (!strncmp(buf, "gc", 2)) + req = 2; + else + req = 1; + fromCmdFile = true; + } + s_seq++; + // Reports also go to /report..txt: a TUI app owns the terminal and stderr text gets lost in its rendering. + struct StderrTee { + int saved = -1; + StderrTee(bool on) + { + if (!on || !s_dir) return; + char p[1200]; + snprintf(p, sizeof p, "%s/report.%d.txt", s_dir, getpid()); + int fd = open(p, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (fd < 0) return; + fflush(stderr); + saved = dup(2); + if (saved < 0) { // no fd left to remember stderr by: leave it alone rather than lose it + close(fd); + return; + } + dup2(fd, 2); + close(fd); + } + ~StderrTee() + { + if (saved < 0) return; + fflush(stderr); + dup2(saved, 2); + close(saved); + } + } tee(fromCmdFile); + if (req == 4) { + fileSnapshotHeap(*vm); + return; + } + if (req == 5) { + dumpDirtyMap(*vm); + return; + } + if (req == 6) { + Bun::StartupSnapshot::recleanFrozenPages(*vm); + return; + } + if (req == 7) { + s_recordProfile = true; + dumpDirtyMap(*vm); + s_recordProfile = false; + return; + } + if (req == 9) { + snapshotTrapReport(); + return; + } + if (req == 10) { + dumpNewCells(*vm); + return; + } + if (req == 13) { + dumpMutatedSnapshotObjects(*vm); + return; + } + if (req == 11) { + dumpNewPayload(*vm); + return; + } + if (req == 12) { + dumpUCBCensus(*vm); + return; + } + if (req == 8) { + Bun__requestSnapshot(vm, getenv("BUN_STARTUP_SNAPSHOT_OUT") ? getenv("BUN_STARTUP_SNAPSHOT_OUT") : "/tmp/bun.snapshot"); // unwinds JS via termination; the run loop takes it at top level and exits + return; + } + if (req == 3) { + JSC::JSLockHolder lock(*vm); + JSC::sanitizeStackForVM(*vm); + vm->deleteAllCode(JSC::DeleteAllCodeIfNotCollecting); + vm->heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + WTF::releaseFastMallocFreeMemory(); + mi_collect(true); + fprintf(stderr, "[memdebug] deleteAllCode + full GC done\n"); + } + if (req == 2) { + JSC::JSLockHolder lock(*vm); + vm->heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + WTF::releaseFastMallocFreeMemory(); + mi_collect(true); + fprintf(stderr, "[memdebug] full GC done; purge_delay=%ld purge_decommits=%ld arena_reserve=%ldKiB\n", mi_option_get(mi_option_purge_delay), mi_option_get(mi_option_purge_decommits), mi_option_get(mi_option_arena_reserve)); + mi_arenas_print(); // per-arena slice maps: what the fresh arenas still hold after everything freeable was freed + { // live bytes outside the snapshot, as mimalloc sees them: the difference to the arenas' dirty pages is fragmentation + struct Live { + size_t bytes = 0, blocks = 0, snapshotBytes = 0; + } live; + auto visitLive = [](const mi_heap_t*, const mi_heap_area_t*, void* block, size_t size, void* arg) { + auto* l = static_cast(arg); + if (!block) return true; + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair((uintptr_t)block, UINTPTR_MAX)); + if (it != frozenRanges.begin() && (uintptr_t)block < std::prev(it)->second) { l->snapshotBytes += size; return true; } + l->bytes += size; l->blocks++; + return true; }; + mi_heap_visit_blocks(mi_heap_main(), true, visitLive, &live); + if (freshHeap) mi_heap_visit_blocks(freshHeap, true, visitLive, &live); + fprintf(stderr, "[memdebug] live malloc outside the snapshot (main + fresh heaps): %.1f MB in %zu blocks (snapshot-resident live: %.1f MB)\n", live.bytes / 1048576.0, live.blocks, live.snapshotBytes / 1048576.0); + + { // The residue question: are the fresh arenas' pages empty-but-retained, or sparsely used? Per page (area), outside the snapshot. + struct Areas { + size_t committed[5] = {}, pages[5] = {}; // buckets: 0%, <10%, <25%, <50%, >=50% used + std::map> bySize; // block size -> (committed in pages under 25% used, live bytes there) + std::map> dirtyBySize; // block size -> (kernel-dirty bytes over all its pages, live bytes) + size_t dirtyTotal = 0, liveTotal = 0; + std::vector disp; + } areas; + auto visitArea = [](const mi_heap_t*, const mi_heap_area_t* area, void*, size_t, void* arg) { + auto* a = static_cast(arg); + uintptr_t start = (uintptr_t)area->blocks; + auto it = std::upper_bound(frozenRanges.begin(), frozenRanges.end(), std::make_pair(start, UINTPTR_MAX)); + if (it != frozenRanges.begin() && start < std::prev(it)->second) return true; // snapshot page + if (!area->committed) return true; + size_t live = area->used * area->full_block_size; +#if OS(DARWIN) + { // what the kernel actually holds for this area: holes the sweep punched are committed to mimalloc but not dirty here + const size_t pg = getpagesize(); + uintptr_t lo = start & ~(pg - 1), hi = (start + area->committed + pg - 1) & ~(pg - 1); + a->disp.assign((hi - lo) / pg, 0); + mach_vm_size_t n = a->disp.size(); + if (mach_vm_page_range_query(mach_task_self(), lo, hi - lo, (mach_vm_address_t)a->disp.data(), &n) == KERN_SUCCESS) { + size_t dirty = 0; + for (size_t k = 0; k < a->disp.size(); k++) + if (a->disp[k] & (VM_PAGE_QUERY_PAGE_DIRTY | VM_PAGE_QUERY_PAGE_COPIED)) dirty += pg; + auto& d = a->dirtyBySize[area->block_size]; + d.first += dirty; + d.second += live; + a->dirtyTotal += dirty; + a->liveTotal += live; + } + } +#endif + double util = (double)live / (double)area->committed; + int b = area->used == 0 ? 0 : util < 0.10 ? 1 : util < 0.25 ? 2 : util < 0.50 ? 3 : 4; + a->committed[b] += area->committed; + a->pages[b]++; + if (b <= 2) { + auto& e = a->bySize[area->block_size]; + e.first += area->committed; + e.second += live; + } + return true; }; + mi_heap_visit_blocks(mi_heap_main(), false, visitArea, &areas); + if (freshHeap) mi_heap_visit_blocks(freshHeap, false, visitArea, &areas); + static const char* names[5] = { "empty", "<10%", "<25%", "<50%", ">=50%" }; + fprintf(stderr, "[memdebug] fresh pages by utilization:"); + for (int b = 0; b < 5; b++) + fprintf(stderr, " %s: %zu pages / %.1f MB", names[b], areas.pages[b], areas.committed[b] / 1048576.0); + fprintf(stderr, "\n[memdebug] committed in pages under 25%% used, by block size (committed MB / live MB):\n"); + std::vector> order; // committed -> size + for (auto& [sz, e] : areas.bySize) + order.push_back({ e.first, sz }); + std::sort(order.rbegin(), order.rend()); + for (size_t k = 0; k < order.size() && k < 16; k++) { + auto& e = areas.bySize[order[k].second]; + fprintf(stderr, " %8zu B blocks: %6.1f MB committed, %5.2f MB live\n", order[k].second, e.first / 1048576.0, e.second / 1048576.0); + } + fprintf(stderr, "[memdebug] fresh pages, kernel-dirty vs live: %.1f MB dirty, %.1f MB live => %.1f MB slack. Slack by block size:\n", areas.dirtyTotal / 1048576.0, areas.liveTotal / 1048576.0, (areas.dirtyTotal > areas.liveTotal ? areas.dirtyTotal - areas.liveTotal : 0) / 1048576.0); + std::vector> slack; // slack bytes -> block size + for (auto& [sz, d] : areas.dirtyBySize) + slack.push_back({ (long long)d.first - (long long)d.second, sz }); + std::sort(slack.rbegin(), slack.rend()); + for (size_t k = 0; k < slack.size() && k < 16; k++) { + auto& d = areas.dirtyBySize[slack[k].second]; + fprintf(stderr, " %8zu B blocks: %6.1f MB dirty, %6.1f MB live, %6.1f MB slack\n", slack[k].second, d.first / 1048576.0, d.second / 1048576.0, slack[k].first / 1048576.0); + } + { // Discriminator: does an explicit idle sweep on this (the JS) thread reclaim anything the census called slack? + mi_purge_holes_stats_t before, after; + mi_purge_holes_stats_get(&before); + auto footprint = []() -> double { +#if !OS(DARWIN) + return -1.0; +#else + task_vm_info_data_t info; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + return task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count) == KERN_SUCCESS ? (double)info.phys_footprint / 1048576.0 : -1.0; +#endif + }; + double fpBefore = footprint(); + mi_on_thread_idle(); + double fpAfter = footprint(); + mi_purge_holes_stats_get(&after); + fprintf(stderr, "[memdebug] phys_footprint around the explicit sweep: %.1f -> %.1f MB\n", fpBefore, fpAfter); + mi_purge_holes_report(); + fprintf(stderr, "[memdebug] explicit mi_on_thread_idle() on this thread: discarded %.1f MB more (total discarded now %.1f MB), pages freed %zu -> %zu, ineligible pages %zu\n", + ((double)after.purged_bytes_total - (double)before.purged_bytes_total) / 1048576.0, after.purged_bytes / 1048576.0, before.pages_freed, after.pages_freed, after.ineligible_pages); + } + } + } + } + std::string base = std::string(s_dir) + "/memdebug." + std::to_string(getpid()) + "." + std::to_string(s_seq); + mi_prof_dump_to_file((base + ".mi.pb").c_str()); + mi_heap_snapshot_to_file((base + ".mi.snap").c_str(), 1); + { + FILE* f = fopen((base + ".mi.stats.txt").c_str(), "w"); + if (f) { + mi_stats_print_out([](const char* msg, void* arg) { fputs(msg, static_cast(arg)); }, f); + fclose(f); + } + } + { + FILE* f = fopen((base + ".jsc.tsv").c_str(), "w"); + if (f) { + dumpJSCHeap(*vm, f); + fclose(f); + } + } + fprintf(stderr, "[memdebug] wrote %s.*\n", base.c_str()); +#if OS(DARWIN) + if (const char* adv = getenv("BUN_MEMDEBUG_MADV")) { + uint64_t* lenPtr = Bun__getStandaloneModuleGraphMachoLength(); + if (uint64_t len = *lenPtr) { // the pointer is to a static; zero length means no payload to advise on + uintptr_t start = reinterpret_cast(lenPtr); + size_t pg = getpagesize(); + uintptr_t alignedStart = (start + pg - 1) & ~(pg - 1); + uintptr_t end = (start + 8 + len) & ~(pg - 1); + if (end > alignedStart) { + int rc = madvise(reinterpret_cast(alignedStart), end - alignedStart, atoi(adv)); + fprintf(stderr, "[memdebug] madvise(%p, %zu, %d) = %d errno=%d\n", (void*)alignedStart, (size_t)(end - alignedStart), atoi(adv), rc, errno); + } else + fprintf(stderr, "[memdebug] payload spans no whole page of its own; nothing to advise\n"); + } + } +#endif +} +#endif // BUN_STARTUP_SNAPSHOT_TOOLING +#if BUN_STARTUP_SNAPSHOT_TOOLING && !BUN_STARTUP_SNAPSHOT_SUPPORTED +extern "C" void Bun__startupSnapshotToolingTick(JSC::VM*) {} +#endif +#pragma clang diagnostic pop diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 3022c09cafe3..5fcc55216311 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -271,6 +271,8 @@ static consteval unsigned getWebKitBytecodeCacheVersion() } #undef WEBKIT_BYTECODE_CACHE_HASH_KEY +extern "C" bool Bun__startupSnapshotMode(); +extern "C" bool Bun__startupSnapshotActive(); extern "C" unsigned getJSCBytecodeCacheVersion() { return getWebKitBytecodeCacheVersion(); @@ -302,13 +304,20 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c // useWasmFaultSignalHandler/FastMemory when ASAN_OPTIONS lacks // allow_user_segv_handler=1, so we don't force it off here. JSC::initialize([&] { + if (const char* a = getenv("BUN_STARTUP_SNAPSHOT_JIT_ADDR")) + JSC::Options::jitMemoryReservationAddress() = strtoull(a, nullptr, 0); + else if (Bun__startupSnapshotMode()) + JSC::Options::jitMemoryReservationAddress() = 0x3c0000000ull; // snapshots: JIT pool at a fixed VA JSC::Options::useWasm() = true; JSC::Options::useJIT() = true; JSC::Options::useBBQJIT() = true; JSC::Options::useConcurrentJIT() = true; // JSC::Options::useSigillCrashAnalyzer() = true; JSC::Options::useSourceProviderCache() = true; - // JSC::Options::useUnlinkedCodeBlockJettisoning() = false; + if (Bun__startupSnapshotMode()) { // compiled executables and snapshot runs: bytecode is in the mmap'd binary there, so cold blocks are cheap to re-decode; plain runs are unchanged + JSC::Options::useUnlinkedCodeBlockJettisoning() = true; + JSC::Options::useUnlinkedCodeBlockJettisoningForBytecodeCache() = true; + } // JSModuleLoader is now a JSCell (not a JSObject) so exposing it as // the global `Loader` would let user code dereference a non-object // and trip JSValue::synthesizePrototype's isSymbol() debug assert. @@ -2008,6 +2017,20 @@ JSC_DEFINE_CUSTOM_SETTER(moduleNamespacePrototypeSetESModuleMarker, (JSGlobalObj return true; } +// Also re-armed after a snapshot restore: the blobs made in the builder describe its descriptors, so each launch makes its own. +void GlobalObject::armStdioBlobs() +{ + m_bunStdin.initLater([](const LazyProperty::Initializer& init) { + init.set(JSC::JSValue::decode(BunObject__createBunStdin(init.owner)).getObject()); + }); + m_bunStderr.initLater([](const LazyProperty::Initializer& init) { + init.set(JSC::JSValue::decode(BunObject__createBunStderr(init.owner)).getObject()); + }); + m_bunStdout.initLater([](const LazyProperty::Initializer& init) { + init.set(JSC::JSValue::decode(BunObject__createBunStdout(init.owner)).getObject()); + }); +} + void GlobalObject::finishCreation(VM& vm) { // Node.js defaults to 10. Must run before Base::finishCreation() materializes @@ -2867,15 +2890,7 @@ void GlobalObject::finishCreation(VM& vm) }); // Initialize LazyProperties for stdin/stderr/stdout - m_bunStdin.initLater([](const LazyProperty::Initializer& init) { - init.set(JSC::JSValue::decode(BunObject__createBunStdin(init.owner)).getObject()); - }); - m_bunStderr.initLater([](const LazyProperty::Initializer& init) { - init.set(JSC::JSValue::decode(BunObject__createBunStderr(init.owner)).getObject()); - }); - m_bunStdout.initLater([](const LazyProperty::Initializer& init) { - init.set(JSC::JSValue::decode(BunObject__createBunStdout(init.owner)).getObject()); - }); + armStdioBlobs(); configureNodeVM(vm, this); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 35a8a4f69ec6..ab1cc0c11229 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -803,6 +803,8 @@ class GlobalObject : public Bun::GlobalScope { Bun::MarkdownTagStrings& markdownTagStrings() { return m_markdownTagStrings; } #include "ZigGeneratedClasses+lazyStructureHeader.h" + void armStdioBlobs(); + void finishCreation(JSC::VM&); private: diff --git a/src/jsc/bindings/ZigSourceProvider.cpp b/src/jsc/bindings/ZigSourceProvider.cpp index 50074091373e..813087d1cb4c 100644 --- a/src/jsc/bindings/ZigSourceProvider.cpp +++ b/src/jsc/bindings/ZigSourceProvider.cpp @@ -117,6 +117,8 @@ Ref SourceProvider::create( auto origin = getSourceOrigin(); Ref bytecode = JSC::CachedBytecode::create(std::span(resolvedSource.bytecode_cache, resolvedSource.bytecode_cache_size), destructor, {}); + if (!resolvedSource.needsDeref) + bytecode->setPayloadIsPersistent(); // embedded in the executable: decoded instruction streams may alias it instead of copying auto provider = adoptRef(*new SourceProvider( globalObject->bunVM(), resolvedSource, diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c084a885ee86..4773454b3c86 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5187,6 +5187,15 @@ void JSC__VM__ensureTerminationExceptionPending(JSC::VM* arg0) vm.traps().handleTraps(JSC::VMTraps::NeedTermination); } +// Throw the (uncatchable) termination exception on the current JS stack right now, rather than arming a trap for the next check. +JSC::EncodedJSValue JSC__VM__throwTerminationExceptionNow(JSC::JSGlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + vm.setHasTerminationRequest(); + throwException(globalObject, scope, vm.ensureTerminationException()); + return {}; +} // These may be called concurrently from another thread. void JSC__VM__notifyNeedTermination(JSC::VM* arg0) { diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 1ead5980b9ba..689361b4a6ba 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -613,22 +613,10 @@ extern "C" void Bun__setCTRLHandler(BOOL add) extern "C" int32_t bun_is_stdio_null[3] = { 0, 0, 0 }; -extern "C" void bun_initialize_process() -{ - // Disable printf() buffering. We buffer it ourselves. - setvbuf(stdout, nullptr, _IONBF, 0); - setvbuf(stderr, nullptr, _IONBF, 0); - -#if OS(LINUX) - // Prevent leaking inherited file descriptors on Linux - // This is less of an issue for macOS due to posix_spawn - // This is best effort, not all linux kernels support close_range or CLOSE_RANGE_CLOEXEC - // To avoid breaking --watch, we skip stdin, stdout, stderr and IPC. - bun_close_range(4, ~0U, CLOSE_RANGE_CLOEXEC); -#endif - #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) - +// Which of fds 0-2 are terminals (termios saved for exit) or were closed (now /dev/null); rerun after a snapshot restore, whose copies describe the build's. +static void bun_detect_stdio() +{ int devNullFd_ = -1; bool anyTTYs = false; @@ -694,6 +682,36 @@ extern "C" void bun_initialize_process() sigaction(SIGTERM, &sa, nullptr); sigaction(SIGINT, &sa, nullptr); } +} +extern "C" void bun_refresh_stdio_after_snapshot_restore() +{ + for (int fd = 0; fd < 3; fd++) { + bun_stdio_tty[fd] = 0; + bun_is_stdio_null[fd] = 0; + bun_stdio_modified[fd] = 0; + } + bun_detect_stdio(); +} +#else +extern "C" void bun_refresh_stdio_after_snapshot_restore() {} +#endif + +extern "C" void bun_initialize_process() +{ + // Disable printf() buffering. We buffer it ourselves. + setvbuf(stdout, nullptr, _IONBF, 0); + setvbuf(stderr, nullptr, _IONBF, 0); + +#if OS(LINUX) + // Prevent leaking inherited file descriptors on Linux + // This is less of an issue for macOS due to posix_spawn + // This is best effort, not all linux kernels support close_range or CLOSE_RANGE_CLOEXEC + // To avoid breaking --watch, we skip stdin, stdout, stderr and IPC. + bun_close_range(4, ~0U, CLOSE_RANGE_CLOEXEC); +#endif + +#if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) + bun_detect_stdio(); #elif OS(WINDOWS) for (int fd = 0; fd <= 2; ++fd) { auto handle = reinterpret_cast(uv_get_osfhandle(fd)); @@ -1110,6 +1128,52 @@ extern "C" uint64_t* Bun__getStandaloneModuleGraphELFVaddr() #endif // OS(DARWIN) / __linux__ +// Whether this executable carries a payload at all; StartupSnapshot.cpp gates on it. (The allocator asks the narrower question below.) +extern "C" __attribute__((visibility("default"), used)) int bun_is_compiled_executable(void) +{ + return BUN_COMPILED.size != 0; +} + +#if OS(DARWIN) || defined(__linux__) // the only builds whose allocator is given this hook (deps/mimalloc.ts) +// Layout of the trailer the standalone graph writes at the end of its payload (StandaloneModuleGraph.rs `Offsets`; the runtime +// that adds the snapshot fields to it also const-asserts these three numbers, so the two cannot drift apart): ... | Offsets (kOffsetsSize bytes) | 16-byte trailer magic. Only the two fields that +// say "marked to take a snapshot" and "carries one" are read here, because this runs before main, from the allocator. +static constexpr size_t kOffsetsSize = 40; +static constexpr size_t kOffsetsFlagsOffset = 28; +static constexpr size_t kOffsetsSnapshotLengthOffset = 36; +static constexpr uint32_t kTakeStartupSnapshotFlag = 1u << 4; +static constexpr char kPayloadTrailer[16] = { '\n', '-', '-', '-', '-', ' ', 'B', 'u', 'n', '!', ' ', '-', '-', '-', '-', '\n' }; + +// Asked by the pinned mimalloc during its own initialization (MI_STARTUP_SNAPSHOT_HOST_FN): deterministic placement is only +// wanted by an executable that is marked to take a snapshot or carries one, so an ordinary compiled executable pays nothing. +extern "C" __attribute__((visibility("default"), used)) int bun_startup_snapshot_placement_wanted(void) +{ + const uint8_t* base; + uint64_t len; +#if OS(DARWIN) + base = BUN_COMPILED.data; + len = BUN_COMPILED.size; +#else + if (!BUN_COMPILED.size) + return 0; + // BUN_COMPILED.size holds the injected payload's address: a BlobHeader-shaped [u64 length][bytes...], but only page-aligned + // (4K on x86-64), so it must not be read through the 16K-aligned type. + const uint8_t* header = reinterpret_cast(static_cast(BUN_COMPILED.size)); + memcpy(&len, header, sizeof len); + base = header + sizeof(uint64_t); +#endif + if (len < kOffsetsSize + sizeof kPayloadTrailer) + return 0; + if (memcmp(base + len - sizeof kPayloadTrailer, kPayloadTrailer, sizeof kPayloadTrailer) != 0) + return 0; + const uint8_t* offsets = base + len - sizeof kPayloadTrailer - kOffsetsSize; + uint32_t flags, snapshotLength; + memcpy(&flags, offsets + kOffsetsFlagsOffset, sizeof flags); + memcpy(&snapshotLength, offsets + kOffsetsSnapshotLengthOffset, sizeof snapshotLength); + return (flags & kTakeStartupSnapshotFlag) != 0 || snapshotLength != 0; +} +#endif + #elif defined(_WIN32) // Windows PE section handling #include @@ -1161,4 +1225,11 @@ extern "C" uint8_t* Bun__getStandaloneModuleGraphPEData() return pe_section_data; } +// Called by StartupSnapshot.cpp's unsupported-platform stubs (Bun__isCompiledExecutable); the PE payload is loaded later by the +// Rust side, and nothing here needs to know about it before then. +extern "C" int bun_is_compiled_executable(void) +{ + return 0; +} + #endif diff --git a/src/jsc/bindings/webcore/AbortSignal.cpp b/src/jsc/bindings/webcore/AbortSignal.cpp index 5ebd0d4aedde..69340ac896ad 100644 --- a/src/jsc/bindings/webcore/AbortSignal.cpp +++ b/src/jsc/bindings/webcore/AbortSignal.cpp @@ -210,6 +210,7 @@ void AbortSignal::runAbortSteps() { Locker locker { m_abortAlgorithmsLock }; abortAlgorithms = std::exchange(m_abortAlgorithms, {}); + m_hasAbortAlgorithms.store(false, std::memory_order_relaxed); } for (auto& pair : abortAlgorithms) pair.second->handleEvent(reason); @@ -317,6 +318,7 @@ uint32_t AbortSignal::addAbortAlgorithmToSignal(AbortSignal& signal, Ref void AbortSignal::visitAbortAlgorithms(Visitor& visitor) { + if (!m_hasAbortAlgorithms.load(std::memory_order_relaxed)) + return; // nothing to visit, and no lock-word write on an otherwise clean (possibly snapshot) page Locker locker { m_abortAlgorithmsLock }; for (auto& pair : m_abortAlgorithms) pair.second->visitJSFunction(visitor); diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h index 1664495dc96f..7331a4a0caaf 100644 --- a/src/jsc/bindings/webcore/AbortSignal.h +++ b/src/jsc/bindings/webcore/AbortSignal.h @@ -206,6 +206,7 @@ class AbortSignal final : public RefCounted, public EventTargetWith // Strong-ref cycle leak. Vector>> m_abortAlgorithms WTF_GUARDED_BY_LOCK(m_abortAlgorithmsLock); Lock m_abortAlgorithmsLock; + std::atomic m_hasAbortAlgorithms { false }; // mirrors m_abortAlgorithms (maintained under the lock) so GC visits can skip without touching the lock word AbortSignalSet m_sourceSignals; AbortSignalSet m_dependentSignals; JSValueInWrappedObject m_reason; diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index aa9643fe10ef..95fdb7c899bb 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -648,6 +648,17 @@ impl EventLoop { pub fn tick(&mut self) { jsc::mark_binding(); + // The request is process-wide but only the main thread's loop may act on it: a worker's loop ticks through here too. + // SAFETY: `vm()` is this loop's live VM. + if self.entered_event_loop_count == 0 + && bun_core::startup_snapshot::snapshot_requested() + && unsafe { (*self.vm()).is_main_thread() } + { + // Requested while idle (or the termination already unwound to here): outermost tick, no JS below us. + (crate::virtual_machine::runtime_hooks() + .expect("hooks") + .take_snapshot)(self.vm()); + } crate::top_scope!(scope, self.global_ref()); self.entered_event_loop_count += 1; // The scope/counter cleanup is inlined at each return site below (a @@ -678,6 +689,18 @@ impl EventLoop { || scope.has_exception() { self.entered_event_loop_count -= 1; + // Only at the outermost tick: a nested tick (wait_for_promise) still has the outer callback's frames below it, + // so it just returns and lets the termination keep unwinding; the outermost one gets here with a count of 0. + if self.entered_event_loop_count == 0 + && bun_core::startup_snapshot::snapshot_requested() + // SAFETY: as above. + && unsafe { (*self.vm()).is_main_thread() } + { + // The termination was ours: every JS frame is gone; hand off to the runtime to write the snapshot. + (crate::virtual_machine::runtime_hooks() + .expect("hooks") + .take_snapshot)(ctx); + } return; } if refills == Self::CONCURRENT_REFILLS_PER_TICK { diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 8476ef60cb1b..a169d551b4f4 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -217,7 +217,7 @@ pub struct RareData { // This does not handle ShadowRealm correctly! pub(crate) cleanup_hooks: Vec, - pub(crate) file_polls: Option>, + pub file_polls: Option>, /// Embedded socket groups for kinds that aren't tied to a Listener / server. /// Lazily linked into the loop on first socket; never separately allocated. @@ -279,6 +279,8 @@ pub struct RareData { pub s3_default_client: Strong, /// Per-VM, like Node's quic `BindingData` (node/src/quic/bindingdata.h). pub node_quic_callbacks: Strong, + /// `Bun.startupSnapshot.main(fn)` registered while the snapshot was being taken; a launch that resumes from it calls this after `'restore'`. + pub startup_snapshot_main: Strong, pub(crate) default_csrf_secret: Box<[u8]>, /// Owned NUL-terminated buffer. `len()` includes the trailing 0; @@ -332,6 +334,7 @@ impl Default for RareData { h2_padded_frame_buffer: None, s3_default_client: Strong::empty(), node_quic_callbacks: Strong::empty(), + startup_snapshot_main: Strong::empty(), default_csrf_secret: Box::default(), tls_default_ciphers: None, spawn_sync_event_loop_: None, @@ -731,6 +734,21 @@ impl RareData { .push(CleanupHook::from(global_this, ctx, func)); } + /// snapshot restore: what the builder drew or derived for itself must not be shared by every restored process — pre-drawn + /// random bytes, the default CSRF secret, and the default S3 client built from the builder's credentials. + pub fn forget_builder_secrets_for_snapshot_restore(&mut self) { + self.entropy_cache = None; + self.default_csrf_secret = Box::default(); + self.s3_default_client.deinit(); + } + + /// snapshot restore: the isolated spawnSync loop (if the builder ever spawnSync'd) wraps the builder's kqueue/epoll fd; forget it so the next spawnSync makes one here. + pub fn forget_spawn_sync_event_loop_for_snapshot_restore(&mut self) { + if let Some(stale) = self.spawn_sync_event_loop_.take() { + Box::leak(stale); // its fds belong to the process that built the snapshot; Drop here would close unrelated fds of ours + } + } + pub fn spawn_sync_event_loop(&mut self, vm: &mut VirtualMachine) -> &mut SpawnSyncEventLoop { if self.spawn_sync_event_loop_.is_none() { // In-place out-param init: `event_loop` inside captures the @@ -1106,6 +1124,17 @@ impl Drop for RareData { } impl RareData { + /// Snapshot restore: the Bun.stdin/stdout/stderr stores describe the builder's descriptors. They are left to the + /// snapshot (JS may still hold Blobs over them); the next use builds stores for this process's descriptors. + pub fn forget_stdio_stores_for_snapshot_restore(&mut self) { + self.stdin_store = None; + self.stdout_store = None; + self.stderr_store = None; + self.stdin_mode = 0; + self.stdout_mode = 0; + self.stderr_mode = 0; + } + /// Detach every embedded socket group from the thread's uSockets loop /// (asserting each is empty). A thread teardown calls this before it frees /// that loop; `Drop` calls it for every other owner. Idempotent. diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 3a1ae1cce08f..040013df30c9 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -190,6 +190,26 @@ impl Drop for WebWorker { } } +/// Worker threads currently running (from the start of a worker's thread until its shutdown). A snapshot cannot contain a +/// thread, so the freeze reports any as a blocker. +static LIVE_WORKERS: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); +/// One unit of `LIVE_WORKERS`, held from before a worker thread is spawned until the last statement of that thread has run. +struct LiveWorker; +impl LiveWorker { + fn begin() -> Self { + LIVE_WORKERS.fetch_add(1, Ordering::Relaxed); + Self + } +} +impl Drop for LiveWorker { + fn drop(&mut self) { + LIVE_WORKERS.fetch_sub(1, Ordering::Relaxed); + } +} +pub fn live_worker_count() -> usize { + LIVE_WORKERS.load(core::sync::atomic::Ordering::Relaxed) +} + impl WebWorker { pub(crate) fn has_requested_terminate(&self) -> bool { self.requested_terminate.load(Ordering::Acquire) @@ -379,9 +399,11 @@ impl WebWorker { // SAFETY: heap-allocated, refcounted; the new thread holds the ref taken above. unsafe impl Send for SendPtr {} let send = SendPtr(worker); + let live = LiveWorker::begin(); // counted before the thread exists, so a freeze cannot slip in between let spawn = std::thread::Builder::new() .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) .spawn(move || { + let _live = live; // released when this closure returns: after teardown and after the thread's ref is dropped let send = send; // SAFETY: `send.0` is live (the thread's ref); `&WebWorker`, never `&mut`. unsafe { (*send.0).thread_main() }; diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index cd202fc83753..446a5f71dc38 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -433,6 +433,18 @@ impl Blob { matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::File(_))) } + /// The I/O class reading or writing this blob counts as while a snapshot is being built (`None` = not gated: memory, or stdio, which is each launch's own). + pub fn snapshot_io_kind(&self) -> Option<&'static str> { + match self.store.get().as_deref().map(|s| &s.data) { + Some(store::Data::File(file)) => match &file.pathlike { + PathOrFileDescriptor::Path(_) => Some("Bun.file"), + PathOrFileDescriptor::Fd(fd) => (!fd.is_stdio()).then_some("Bun.file"), + }, + Some(store::Data::S3(_)) => Some("Bun.s3"), // network-class: refused under strict and local alike + _ => None, + } + } + /// `Blob.getFileName()` — the user-visible name: `Bytes.stored_name`, /// the file path, or the S3 key. `None` for fd-backed or unnamed blobs. pub fn get_file_name(&self) -> Option<&[u8]> { diff --git a/src/mimalloc_sys/mimalloc.rs b/src/mimalloc_sys/mimalloc.rs index ad11e0e8536b..32262dbdb10f 100644 --- a/src/mimalloc_sys/mimalloc.rs +++ b/src/mimalloc_sys/mimalloc.rs @@ -29,6 +29,9 @@ unsafe extern "C" { /// free blocks inside its still-used pages, and hands the arena purge to the scavenger. /// Safe on any thread; a no-op on a thread that never allocated. No preconditions. pub safe fn mi_on_thread_idle(); + /// Whether this process places its OS reservations deterministically (an executable that can carry a heap + /// snapshot, or `MIMALLOC_DETERMINISTIC_HINT=1`); decided once. No preconditions. + pub safe fn mi_startup_snapshot_hints_enabled() -> bool; pub fn mi_stats_print_out(out: core::option::Option, arg: *mut c_void); pub fn mi_process_info( elapsed_msecs: *mut usize, 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.rs b/src/runtime/api.rs index 76bf14fbf80a..8322184bd5bd 100644 --- a/src/runtime/api.rs +++ b/src/runtime/api.rs @@ -77,6 +77,8 @@ pub mod native_promise_context; pub mod output_file_jsc; #[path = "api/standalone_graph_jsc.rs"] pub mod standalone_graph_jsc; +#[path = "api/StartupSnapshotObject.rs"] +pub mod startup_snapshot_object; #[path = "api/TOMLObject.rs"] pub mod toml_object; #[path = "api/UnsafeObject.rs"] @@ -188,6 +190,7 @@ pub use crate::api::js_transpiler as JSTranspiler; pub use crate::api::json5_object as JSON5Object; pub use crate::api::markdown_object as MarkdownObject; pub use crate::api::native_promise_context as NativePromiseContext; +pub use crate::api::startup_snapshot_object as StartupSnapshotObject; pub use crate::api::toml_object as TOMLObject; pub use crate::api::unsafe_object as UnsafeObject; pub use crate::api::xml_object as XMLObject; diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index fdedb2be588f..deadf675fc15 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -93,7 +93,9 @@ use bun_sys::{self as sys, Fd, FdExt as _}; use bun_zlib as zlib; use crate::api::csrf_jsc; -use crate::api::{HashObject, JSON5Object, TOMLObject, UnsafeObject, XMLObject, YAMLObject}; +use crate::api::{ + HashObject, JSON5Object, StartupSnapshotObject, TOMLObject, UnsafeObject, XMLObject, YAMLObject, +}; use crate::crypto as Crypto; use crate::node; use crate::test_runner::jest::Jest; @@ -356,6 +358,7 @@ pub mod bun_object { BunObject_lazyPropCb_origin => super::get_origin, BunObject_lazyPropCb_semver => super::get_semver, BunObject_lazyPropCb_unsafe => super::get_unsafe, + BunObject_lazyPropCb_startupSnapshot => super::get_startup_snapshot, BunObject_lazyPropCb_S3Client => super::get_s3_client_constructor, BunObject_lazyPropCb_s3 => super::get_s3_default_client, BunObject_lazyPropCb_ValkeyClient => super::get_valkey_client_constructor, @@ -2040,6 +2043,10 @@ fn get_unsafe(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { UnsafeObject::create(global_this) } +fn get_startup_snapshot(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { + StartupSnapshotObject::create(global_this) +} + /// EnvironmentVariables is runtime defined. /// Also, you can't iterate over process.env normally since it only exists at build-time otherwise fn get_csrf_object(global_object: &JSGlobalObject, _: &JSObject) -> JSValue { 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/StartupSnapshotObject.rs b/src/runtime/api/StartupSnapshotObject.rs new file mode 100644 index 000000000000..6b34a9c80a06 --- /dev/null +++ b/src/runtime/api/StartupSnapshotObject.rs @@ -0,0 +1,167 @@ +//! `Bun.startupSnapshot`, the app-facing side of `bun build --snapshot`; `process.on('restore')` is the hook that runs in a resumed process. +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult}; + +pub(crate) fn create(global: &JSGlobalObject) -> JSValue { + jsc::create_host_function_object( + global, + &[ + ("main", __jsc_host_main, 1), + ("take", __jsc_host_take, 1), + ("isBuildingSnapshot", __jsc_host_is_building_snapshot, 0), + ("epoch", __jsc_host_epoch, 0), + ("reclean", __jsc_host_reclean, 0), + ], + ) +} + +/// `take({ timers, envGate })`: in the snapshot run, unwind JS with an uncatchable termination and write the snapshot from the top of the event loop; a no-op everywhere else. +#[bun_jsc::host_fn] +fn take(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + let [opts] = frame.arguments_as_array::<1>(); + // Apps call this unconditionally at their "ready" point; only the run `bun build --snapshot` started acts on it. + if !bun_core::startup_snapshot::building() { + return Ok(JSValue::UNDEFINED); + } + if !opts.is_undefined_or_null() && !opts.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "take() takes an options object: {{ timers, envGate }}" + ))); + } + if opts.is_object() { + if let Some(v) = opts.get(global, "timers")? { + let mode = v.to_bun_string(global)?; + let mode = if mode.eql_comptime("keep") { + bun_core::startup_snapshot::StartupSnapshotTimers::Keep + } else if mode.eql_comptime("cancel") { + bun_core::startup_snapshot::StartupSnapshotTimers::Cancel + } else { + return Err(global.throw_invalid_arguments(format_args!( + "take: `timers` must be \"keep\" or \"cancel\"" + ))); + }; + bun_core::startup_snapshot::set_snapshot_timers(mode); + } + // envGate: variables the snapshotted boot depended on; their build-time values travel with the snapshot and a launch that differs boots normally. + if let Some(names) = opts.get(global, "envGate")? { + if !names.is_undefined_or_null() { + let mut it = names.array_iterator(global)?; + let mut joined: Vec = Vec::new(); + while let Some(name) = it.next()? { + let name = name.to_bun_string(global)?.to_owned_slice(); + if name.is_empty() + || bun_core::strings::contains_char(&name, 0) + || bun_core::strings::contains_char(&name, b'=') + { + return Err(global.throw_invalid_arguments(format_args!( + "take: envGate entries must be non-empty variable names" + ))); + } + joined.extend_from_slice(&name); + joined.push(0); + } + if joined.len() > 4096 { + return Err(global.throw_invalid_arguments(format_args!( + "take: envGate names total {} bytes; the limit is 4096", + joined.len() + ))); + } + Bun__startupSnapshotSetEnvGate(joined.as_ptr(), joined.len()); + } + } + } + if bun_core::startup_snapshot::snapshot_in_progress() { + return Ok(JSValue::UNDEFINED); // the runtime is already draining the process (auto mode, or an earlier call): the options above still apply + } + let Some(out) = bun_core::env_var::BUN_STARTUP_SNAPSHOT_OUT.get() else { + return Ok(JSValue::UNDEFINED); + }; + bun_core::startup_snapshot::request_snapshot(out); + crate::cli::run_command::unwind_for_startup_snapshot(global.vm()); + // Unwind every JS frame right now; the outermost EventLoop::tick sees the request and writes the snapshot. + JSC__VM__throwTerminationExceptionNow(global); + Err(jsc::JsError::Thrown) +} + +unsafe extern "C" { + safe fn JSC__VM__throwTerminationExceptionNow(global: &JSGlobalObject) -> JSValue; + /// NUL-separated variable names; copied by the callee. + safe fn Bun__startupSnapshotSetEnvGate(names: *const u8, len: usize); +} + +/// `reclean()`: in a restored process, pages whose bytes drifted back to the snapshot's go back to the clean file mapping (~10ms; call when idle). +#[bun_jsc::host_fn] +fn reclean(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + if bun_core::startup_snapshot::restored() { + Bun__startupSnapshotRecleanPages(global.vm()); + } + Ok(JSValue::UNDEFINED) +} + +unsafe extern "C" { + safe fn Bun__startupSnapshotRecleanPages(vm: &bun_jsc::VM); +} + +/// `Bun.startupSnapshot.isBuildingSnapshot()`: true only in the run `bun build --snapshot` makes to take the snapshot. +#[bun_jsc::host_fn] +fn is_building_snapshot(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + Ok(JSValue::from(bun_core::startup_snapshot::building())) +} + +/// `Bun.startupSnapshot.epoch()`: 0 in a process that booted normally, N in one resumed from a snapshot (N counts restores). +#[bun_jsc::host_fn] +fn epoch(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + Ok(JSValue::js_number( + bun_core::startup_snapshot::epoch() as f64 + )) +} + +/// `main(fn)`: run now in an ordinary launch; kept aside (not run) in the snapshot run; run after `'restore'` in a resumed launch, with that launch's argv/cwd/env/stdio. +#[bun_jsc::host_fn] +fn main(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + let [callback] = frame.arguments_as_array::<1>(); + if !callback.is_callable() { + return Err(global.throw_invalid_arguments(format_args!( + "Bun.startupSnapshot.main() expects a function" + ))); + } + let slot = &mut VirtualMachine::get() + .as_mut() + .rare_data() + .startup_snapshot_main; + if slot.has() { + // The snapshot run could only keep one; the ordinary launch agrees rather than quietly behaving differently. + return Err(global.throw_invalid_arguments(format_args!( + "Bun.startupSnapshot.main() was already called: a program has one main function" + ))); + } + slot.set(global, callback); + if bun_core::startup_snapshot::building() { + return Ok(JSValue::UNDEFINED); + } + callback.call(global, JSValue::UNDEFINED, &[]) +} + +/// Called by the restore sequence after the `'restore'` listeners have run. +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotRunMain(global: &JSGlobalObject) { + let vm = VirtualMachine::get().as_mut(); + let Some(callback) = vm.rare_data().startup_snapshot_main.get() else { + return; + }; + if let Err(err) = callback.call(global, JSValue::UNDEFINED, &[]) { + let exception = global.take_exception(err); + vm.run_error_handler(exception, None); + crate::cli::run_command::exit_with_unhandled_note(vm); // as a throw at module scope ends a normal boot: exit hooks, then 1 + } +} + +/// Asked by the snapshot writer: a snapshot taken with a `main()` registered is valid for any invocation. +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotHasMain() -> bool { + VirtualMachine::get() + .as_mut() + .rare_data() + .startup_snapshot_main + .has() +} diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index c9045837f228..e73c9ce3eedd 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -306,6 +306,7 @@ fn spawn_maybe_sync( args_: JSValue, secondary_args_value: Option, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("Bun.spawn")?; if IS_SYNC { // We skip this on Windows due to test failures. #[cfg(not(windows))] 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/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 292db89d5bfd..01450b15a567 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1357,47 +1357,30 @@ pub mod command { // init to `bun_core::argv()`'s lazy `Once`, so force that init // now — otherwise `bun_options_argc()` reads 0 here and the // standalone executable silently drops `BUN_OPTIONS` flags. - let original_argv_len = bun::argv().len(); - let bun_options_argc = bun::bun_options_argc(); - if !graph.compile_exec_argv.is_empty() || bun_options_argc > 0 { - let mut argv_list: Vec<&'static bun_core::ZStr> = bun::argv().to_vec(); - if !graph.compile_exec_argv.is_empty() { - bun::append_options_env(graph.compile_exec_argv, &mut argv_list); - } - - // Store the full argv including user arguments - let full_argv: &'static [&'static bun_core::ZStr] = bun::intern_argv(argv_list); - let num_exec_argv_options = full_argv.len().saturating_sub(original_argv_len); - - // Calculate offset: skip executable name + all exec argv options + BUN_OPTIONS args - let num_parsed_options = num_exec_argv_options + bun_options_argc; - offset_for_passthrough = if full_argv.len() > 1 { - 1 + num_parsed_options - } else { - 0 - }; - - // Temporarily set bun.argv to only include executable name + exec_argv options + BUN_OPTIONS args. - // This prevents user arguments like --version/--help from being intercepted - // by Bun's argument parser (they should be passed through to user code). + // The executable's compile-time options are part of argv (spliced after argv[0], like BUN_OPTIONS); + // register them before argv is derived, then everything downstream sees one consistent view. + // SAFETY: single-threaded startup, before the first `bun::argv()` read below. + unsafe { bun_core::set_compile_exec_argv(graph.compile_exec_argv) }; + let full_argv: &'static [&'static bun_core::ZStr] = bun::argv().as_slice(); + let num_parsed_options = bun_core::compile_exec_argc() + bun::bun_options_argc(); + offset_for_passthrough = if full_argv.len() > 1 { + 1 + num_parsed_options + } else { + 1.min(full_argv.len()) + }; + bun_core::set_standalone_passthrough_offset(offset_for_passthrough); + if num_parsed_options > 0 { + // Parse only executable name + spliced options: user arguments like --version/--help must pass + // through to user code, not be intercepted by Bun's argument parser. // SAFETY: single-threaded startup; `full_argv` is process-static. unsafe { - bun::set_argv(&full_argv[..(1 + num_parsed_options).min(full_argv.len())]); - } - - // Handle actual options to parse. + bun::set_argv(&full_argv[..(1 + num_parsed_options).min(full_argv.len())]) + }; let result = init(Tag::AutoCommand, log)?; - - // Restore full argv so passthrough calculation works correctly // SAFETY: single-threaded startup. unsafe { bun::set_argv(full_argv) }; - break 'brk result; } - - // If no compile_exec_argv, skip executable name if present - offset_for_passthrough = 1.min(bun::argv().len()); - break 'brk write_context_no_parse(log); }; diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index aa99cd121241..954b65662bfb 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -952,6 +952,7 @@ Full documentation is available at https://bun.com/docs/cli/run // `vm.preload`/`vm.argv` are `Vec>` on both sides; // hand the CLI's vectors over wholesale (process-lifetime, never freed). vm.preload = std::mem::take(&mut ctx.preloads); + record_passthrough_offset(&ctx.passthrough); vm.argv = std::mem::take(&mut ctx.passthrough); // `InitOptions` has no `store_fd` field, so set it on the resolver directly. vm.transpiler.resolver.store_fd = ctx.debug.hot_reload != cli::command::HotReload::None; @@ -1172,6 +1173,7 @@ Full documentation is available at https://bun.com/docs/cli/run let vm = unsafe { &mut *vm_ptr }; vm.preload = std::mem::take(&mut ctx.preloads); + record_passthrough_offset(&ctx.passthrough); vm.argv = std::mem::take(&mut ctx.passthrough); // `vm.main` is a BACKREF (`*const [u8]`) into `entry_path`'s heap @@ -1492,6 +1494,10 @@ impl Run { } } + if bun_core::startup_snapshot::building() { + // SAFETY: the VM and its global object are live on this thread; nothing of the app has run yet. + unsafe { Bun__Process__useSharedEnvForSnapshotBuild(vm.global) }; + } match vm.load_entry_point(entry) { Ok(promise) => { // SAFETY: `promise` is a live GC cell returned by the module loader. @@ -1534,6 +1540,23 @@ impl Run { Err(err) => entry_point_load_failed(vm, &err.into()), } + // `--snapshot` (auto): the entry point has been evaluated; take the snapshot as soon as whatever it started has + // drained. An app that wants to choose the moment calls Bun.startupSnapshot.take() itself (manual mode). + if bun_core::startup_snapshot::building() + && bun_core::env_var::BUN_STARTUP_SNAPSHOT_AUTO + .get() + .unwrap_or(false) + && !bun_core::startup_snapshot::snapshot_requested() + { + if let Some(out) = bun_core::env_var::BUN_STARTUP_SNAPSHOT_OUT.get() { + bun_core::startup_snapshot::set_snapshot_timers( + bun_core::startup_snapshot::StartupSnapshotTimers::Keep, + ); + bun_core::startup_snapshot::request_snapshot(out); + take_startup_snapshot_and_exit(vm); + } + } + // don't run the GC if we don't actually need to if vm.is_event_loop_alive() || vm.event_loop_ref().tick_concurrent_with_count() > 0 { vm.global().vm().release_weak_refs(); @@ -1624,31 +1647,462 @@ impl Run { vm.on_before_exit(); } - if log_has_msgs(vm) { - dump_build_error(vm); - Output::flush(); + finish_run_and_exit(vm); + } +} + +// Snapshot: a process restored from a snapshot jumps here instead of loading an entry point. +// The Rust `VirtualMachine`/event loop objects come from the snapshot (heap); only thread-locals need re-seating. +unsafe extern "C" { + fn Bun__startupSnapshotDumpNow(vm: *mut bun_jsc::VM, path: *const ::core::ffi::c_char) -> bool; + safe fn Bun__startupSnapshotClearTerminationRequest(vm: &bun_jsc::VM); + safe fn Bun__startupSnapshotUnwindJS(vm: &bun_jsc::VM); +} + +/// `process.argv`/`Bun.argv` are derived from the live process argv whenever the script's arguments are exactly its +/// tail (`bun [flags] entry a b`, compiled executables), so they follow the launch of a process restored from a snapshot; +/// shapes that rearrange the arguments (`-e code a b` merges positionals back in, stdin mode prepends "-") keep the +/// CLI's own list. Decided here, on the list the VM actually gets, not on an intermediate one. +fn record_passthrough_offset(passthrough: &[Box<[u8]>]) { + let all = bun_core::argv(); + let offset = match all.len().checked_sub(passthrough.len()) { + Some(offset) + if all + .iter() + .skip(offset) + .zip(passthrough) + .all(|(a, b)| a == &b[..]) => + { + offset } + _ => 0, + }; + bun_core::set_standalone_passthrough_offset(offset); +} - vm.on_unhandled_rejection = Run::on_unhandled_rejection_before_close; - vm.global().handle_rejected_promises(); - vm.on_exit(); +/// Under BUN_STARTUP_SNAPSHOT_IO=local, everything the app touched on this machine before the freeze — the results are in the snapshot. +fn print_local_io_audit() { + let stdio = bun_core::startup_snapshot::take_stdio_notes(); + if !stdio.is_empty() { + bun_core::Output::print_errorln(format_args!( + "snapshot: process.stdin/stdout/stderr were set up before the freeze; the streams are re-created at restore, but anything derived from them here (isTTY, color support) describes this build's descriptors, not the user's:" + )); + for (fd, site) in &stdio { + let name = match fd { + 0 => "process.stdin", + 1 => "process.stdout", + _ => "process.stderr", + }; + bun_core::Output::print_errorln(format_args!( + " {name} from:\n{}", + bstr::BStr::new(site) + )); + } + } + let audit = bun_core::startup_snapshot::take_local_io_audit(); + if audit.is_empty() { + bun_core::Output::flush(); + return; + } + let uses: u32 = audit.iter().map(|(_, _, n)| n).sum(); + bun_core::Output::print_errorln(format_args!( + "snapshot: {uses} local I/O operations ran before the freeze (allowed by --snapshot-io); whatever they produced is in the snapshot:" + )); + for (kind, site, count) in &audit { + bun_core::Output::print_errorln(format_args!( + " {kind} x{count} from:\n{}", + bstr::BStr::new(site) + )); + } + bun_core::Output::flush(); +} + +/// The app asked for a snapshot and every JS frame has unwound (termination). Quiesce the runtime and write the snapshot from the top of the run loop. +pub fn take_startup_snapshot_and_exit(vm: &mut bun_jsc::virtual_machine::VirtualMachine) -> ! { + let Some(path) = bun_core::startup_snapshot::take_snapshot_request() else { + unreachable!() + }; + bun_core::startup_snapshot::set_snapshot_in_progress(); + Bun__startupSnapshotClearTerminationRequest(vm.jsc_vm()); // the request unwound JS with a termination; the quiesce below runs JS again + // Only a quiet process makes a sound snapshot: let in-flight work finish (bounded), and refuse to dump over anything still pending. + let deadline = std::time::Instant::now() + + std::time::Duration::from_secs( + bun_core::env_var::BUN_STARTUP_SNAPSHOT_QUIET_TIMEOUT + .get() + .unwrap_or(15), + ); + let cancel_timers = bun_core::startup_snapshot::snapshot_timers() + == bun_core::startup_snapshot::StartupSnapshotTimers::Cancel; + loop { + if cancel_timers { + // The app asked us to drop its (self-re-arming) timers; do it every round since draining runs JS that may arm more. + let state = crate::jsc_hooks::runtime_state(); + if !state.is_null() { + // SAFETY: main thread; RuntimeState/VM live; JS not on the stack. + unsafe { + crate::timer::All::cancel_all_timeout_objects(&raw mut (*state).timer, vm) + }; + } + } + let blockers = snapshot_blockers(vm); + if blockers.is_empty() { + break; + } + if std::time::Instant::now() >= deadline { + bun_core::Output::err_generic( + "snapshot: process did not become quiet: {}", + (blockers.join(", "),), + ); + bun_core::Output::flush(); + bun_core::Global::exit(70); + } + vm.tick(); + vm.auto_tick(); + } + // Quiet is not enough: no other thread of ours may be mid-anything (e.g. inside free() holding an allocator lock) when memory is frozen. + #[cfg(target_os = "macos")] + crate::dns_jsc::dns_sd::SharedConnection::close_for_terminate(); // the mDNSResponder connection is per-process; a fresh one is opened on the first lookup after restore + #[cfg(target_os = "macos")] + crate::node::fs_events::shutdown_for_snapshot(); + #[cfg(any(target_os = "linux", target_os = "android"))] + if !crate::node::path_watcher::shutdown_for_snapshot() { + bun_core::Output::err_generic( + "snapshot: the fs.watch thread did not stop; no snapshot taken", + (), + ); + bun_core::Output::flush(); + bun_core::Global::exit(70); + } + bun_threading::work_pool::WorkPool::stop_all_threads_for_snapshot(); + // The HTTP client thread exists once anything fetched (allowed by --snapshot-io=network); a no-op if it never started. + if !bun_http::http_thread::shutdown_for_exit() { + bun_core::Output::err_generic( + "snapshot: the HTTP client thread did not stop; no snapshot taken", + (), + ); + bun_core::Output::flush(); + bun_core::Global::exit(70); + } + bun_http::http_thread::reset_shutdown_state_for_snapshot(); + // The subprocess waiter thread exists once something was spawned on a build using it (no pidfd, or forced); Linux can stop it. + { + use bun_spawn::process::WaiterThread; + let was_running = WaiterThread::is_running(); + if !matches!(WaiterThread::stop_for_snapshot(), Ok(true)) { + bun_core::Output::err_generic( + "snapshot: the subprocess waiter thread did not stop; no snapshot taken", + (), + ); + bun_core::Output::flush(); + bun_core::Global::exit(70); + } + if was_running + && bun_core::env_var::BUN_STARTUP_SNAPSHOT_VERBOSE + .get() + .is_some() + { + bun_core::Output::print_errorln("[snapshot] stopped the subprocess waiter thread"); + } + } + { + let now = bun_core::Timespec::now(bun_core::TimespecMockMode::ForceRealTime); + bun_core::startup_snapshot::SNAPSHOT_MONOTONIC[0] + .store(now.sec, ::std::sync::atomic::Ordering::Relaxed); + bun_core::startup_snapshot::SNAPSHOT_MONOTONIC[1] + .store(now.nsec, ::std::sync::atomic::Ordering::Relaxed); + } + print_local_io_audit(); + let cpath = std::ffi::CString::new(path).unwrap(); + // SAFETY: main thread, VM live, no JS on the stack. + let written = unsafe { + Bun__startupSnapshotDumpNow( + ::core::ptr::from_ref(vm.jsc_vm()).cast_mut(), + cpath.as_ptr(), + ) + }; + bun_core::Global::exit(if written { 0 } else { 1 }); // the dump already said why it declined +} + +/// What still ties this process to work in flight; the snapshot is only written when this is empty. +fn snapshot_blockers(vm: &mut bun_jsc::virtual_machine::VirtualMachine) -> Vec { + let mut out = Vec::new(); + if let Some(msg) = bun_spawn::process::WaiterThread::snapshot_blocker() { + out.push(msg.to_string()); + } + if let Some(msg) = bun_io::io_watcher_snapshot_blocker() { + out.push(msg.to_string()); + } + match bun_jsc::web_worker::live_worker_count() { + 0 => {} + n => out.push(format!("{n} worker thread(s) still running — a snapshot cannot contain a thread; terminate them (await worker.terminate()) before snapshotting")), + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + if vm + .rare_data() + .file_polls + .as_deref() + .is_some_and(|store| store.inline_hive_is_full()) + { + out.push("128 or more open file polls (the ones beyond the inline table could not be re-armed after a restore) — close some before snapshotting".into()); + } + let el = vm.event_loop_shared(); + if vm.active_tasks > 0 { + out.push(format!( + "{} active async tasks (fetch/fs/spawn awaiting completion)", + vm.active_tasks + )); + } + if el.tasks.readable_length() > 0 + || !el.immediate_tasks.is_empty() + || !el.next_immediate_tasks.is_empty() + { + out.push("queued tasks/immediates".into()); + } + if el.has_pending_refs() { + out.push("pending cross-thread refs".into()); + } + let http = bun_http::active_requests_count(); + if http > 0 { + out.push(format!("{http} HTTP requests in flight")); + } + let state = crate::jsc_hooks::runtime_state(); + if !state.is_null() { + // SAFETY: main-thread RuntimeState. + let armed = unsafe { (*state).timer.active_timer_count }; + if armed > 0 + && bun_core::startup_snapshot::snapshot_timers() + != bun_core::startup_snapshot::StartupSnapshotTimers::Keep + { + out.push(format!("{armed} ref'd timers armed (setTimeout/setInterval/AbortSignal.timeout) — clear them before snapshotting")); + } + } + let (busy, queued) = bun_threading::work_pool::WorkPool::get().activity(); + if busy > 0 || queued { + out.push(format!( + "thread pool: {busy} busy workers{}", + if queued { ", queue non-empty" } else { "" } + )); + } + out +} - if ANY_UNHANDLED.load(Ordering::Relaxed) { - print_unhandled_version_note(vm); +/// `Bun.startupSnapshot.take()` / cmd-file trigger: leave JS via an uncatchable termination and snapshot from the run loop. +/// +/// # Safety +/// `path` must point to a NUL-terminated string that outlives the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__requestSnapshot(vm: &bun_jsc::VM, path: *const ::core::ffi::c_char) { + // SAFETY: per the function contract. + let path = unsafe { ::core::ffi::CStr::from_ptr(path) }.to_bytes(); + bun_core::startup_snapshot::request_snapshot(path); + unwind_for_startup_snapshot(vm); +} + +/// A snapshot has been requested: unwind whatever JS is running (termination trap) and wake the loop, whose outermost +/// tick takes it. +pub(crate) fn unwind_for_startup_snapshot(vm: &bun_jsc::VM) { + Bun__startupSnapshotUnwindJS(vm); + // SAFETY: called on the JS thread; the main-thread VM, if any, outlives this call. + let main = unsafe { bun_jsc::virtual_machine::VirtualMachine::main_thread_vm_ptr().as_mut() }; + if let Some(main) = main { + main.wakeup(); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotSetBuilding(on: bool) { + bun_core::startup_snapshot::set_building(on); +} + +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotIsBuilding() -> bool { + bun_core::startup_snapshot::building() +} + +/// # Safety +/// `site` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__startupSnapshotNoteStdioStream(fd: i32, site: *const u8, len: usize) { + // SAFETY: per the contract above. + let site = unsafe { ::core::slice::from_raw_parts(site, len) }; + bun_core::startup_snapshot::note_stdio_stream(fd, site.to_vec()); +} + +unsafe extern "C" { + fn Bun__Process__useSharedEnvForSnapshotBuild(global: *mut bun_jsc::JSGlobalObject); + fn Bun__Process__reloadEnvAfterSnapshotRestore(global: *mut bun_jsc::JSGlobalObject); + fn Bun__refreshTimeZoneAfterSnapshotRestore( + global: *mut bun_jsc::JSGlobalObject, + tz: *const u8, + tz_len: usize, + ); + fn Bun__Process__recreateStdioAfterSnapshotRestore(global: *mut bun_jsc::JSGlobalObject); + fn Bun__BunObject__refreshLaunchDerivedProperties(global: *mut bun_jsc::JSGlobalObject); + safe fn Bun__Process__reinstallSignalHandlersAfterSnapshotRestore(); + fn Bun__VM__refreshStackBoundsAfterSnapshotRestore(vm: *mut bun_jsc::VM); +} + +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotAdoptMainThreadVM() { + let vm_ptr = bun_jsc::virtual_machine::VirtualMachine::main_thread_vm_ptr(); + assert!(!vm_ptr.is_null(), "snapshot has no main-thread VM"); + bun_core::startup_snapshot::did_restore(); + // SAFETY: this is the restore, on the only thread of the new process so far. + unsafe { bun_boringssl_sys::reinit_fork_detection_after_snapshot_restore() }; + bun_threading::work_pool::WorkPool::did_restore_from_snapshot(); + bun_jsc::virtual_machine::VirtualMachine::adopt_on_current_thread(vm_ptr); + { + // SAFETY: main-thread VM. Its cached stack top/limits are the builder's; refresh before anything can allocate JS objects (GC sanitizes the stack). + let vm = unsafe { &mut *vm_ptr }; + // SAFETY: `vm.jsc_vm` is the snapshot's live JSC VM, now owned by this thread. + unsafe { Bun__VM__refreshStackBoundsAfterSnapshotRestore(vm.jsc_vm) }; + bun_core::StackCheck::configure_thread(); // the runtime's own recursion guard is a thread-local: empty here until set, i.e. no guard at all + } + { + // The resolver/node:fs "top level dir" is the builder's cwd; re-read where this process runs. + // SAFETY: process-global FileSystem singleton; single-threaded at this point of restore. + let fs = bun_resolver::fs::FileSystem::instance(); + let mut tmp = bun_paths::PathBuffer::uninit(); + if let bun_sys::Result::Ok(cwd) = bun_sys::getcwd_z(&mut tmp) { + let n = cwd.as_bytes().len(); + fs.top_level_dir_buf[..n].copy_from_slice(cwd.as_bytes()); + // SAFETY: `top_level_dir_buf` lives in the process-lifetime FileSystem singleton. + let dir: &'static [u8] = + unsafe { ::core::slice::from_raw_parts(fs.top_level_dir_buf.as_ptr(), n) }; + fs.set_top_level_dir(dir); } + } + { + // SAFETY: main-thread VM; the env loader is the builder's — replace its process-derived entries with this process's environment. + let vm = unsafe { &mut *vm_ptr }; + // SAFETY: `transpiler.env` is the process-lifetime loader; nothing else touches it during restore adoption. + if let Some(env) = unsafe { vm.transpiler.env.as_mut() } { + let _ = env.reload_process_after_snapshot_restore(); + } + vm.adopt_ipc_channel_from_env(); // before process.env is rebuilt below: this scrubs the channel variable, as boot does + // SAFETY: FFI; rebuilds the JS `process.env` object from the (now current) loader map. + unsafe { Bun__Process__reloadEnvAfterSnapshotRestore(vm.global) }; + // Descriptors 0-2 are this launch's, but everything derived from the builder's is in the snapshot: colors (Output), the + // Bun.stdout/stderr/stdin stores, and any process.std* stream the app created. (The C-level tty flags and saved termios + // were refreshed by the C++ restore itself, before it re-applied the builder's terminal mode; doing it again here would + // capture that mode as the state to restore at exit.) + bun_core::Output::Source::refresh_stdio_after_snapshot_restore(); + vm.rare_data().forget_stdio_stores_for_snapshot_restore(); + // SAFETY: FFI; main-thread global, single-threaded at this point of restore. + unsafe { Bun__Process__recreateStdioAfterSnapshotRestore(vm.global) }; + // After the stdio/color refresh above: Bun.enableANSIColors is derived from it, so re-putting it any earlier would + // just re-put the builder's answer. + vm.rare_data().forget_builder_secrets_for_snapshot_restore(); // before the refresh below: Bun.s3's callback returns the cached client if one is there + // SAFETY: FFI; same conditions as the call above. + unsafe { Bun__BunObject__refreshLaunchDerivedProperties(vm.global) }; + Bun__Process__reinstallSignalHandlersAfterSnapshotRestore(); // process.on('SIGINT') etc. registered before the snapshot + } + { + // SAFETY: main-thread VM; single-threaded at this point of restore. + let vm = unsafe { &mut *vm_ptr }; + vm.forget_env_derived_defaults_for_snapshot_restore(); + vm.handle().readopt_js_thread(); + crate::node::node_fs_stat_watcher::StatWatcher::readopt_main_thread_after_snapshot_restore( + vm, + ); + { + let tz: &[u8] = vm.env_loader().get(b"TZ").unwrap_or(&[]); // this launch's, since the loader was just reloaded + // SAFETY: FFI; `tz` is borrowed from the loader for the duration of the call, and `vm.global` is the live global. + unsafe { Bun__refreshTimeZoneAfterSnapshotRestore(vm.global, tz.as_ptr(), tz.len()) }; + } + vm.origin_timer = std::time::Instant::now(); // performance.now()/process.uptime()/hrtime count from this launch, not the builder's + vm.origin_timestamp = bun_jsc::virtual_machine::get_origin_timestamp(); + } + crate::jsc_hooks::adopt_main_thread_runtime_state(); + { + // Timers armed before the freeze carry the building process's monotonic deadlines; shift them by the difference between + // that clock and this one. Has to come after the runtime state is adopted (it lives in a thread-local this thread did + // not have a moment ago) — before that point there is nothing to rebase and the kept timers would silently stay on the + // builder's clock. + let state = crate::jsc_hooks::runtime_state(); + debug_assert!(!state.is_null(), "runtime state adopted just above"); + if !state.is_null() { + let then = bun_core::Timespec { + sec: bun_core::startup_snapshot::SNAPSHOT_MONOTONIC[0] + .load(::std::sync::atomic::Ordering::Relaxed), + nsec: bun_core::startup_snapshot::SNAPSHOT_MONOTONIC[1] + .load(::std::sync::atomic::Ordering::Relaxed), + }; + let now = bun_core::Timespec::now(bun_core::TimespecMockMode::ForceRealTime); + // SAFETY: main thread; RuntimeState live; no JS on the stack. + let moved = unsafe { (*state).timer.rebase_after_snapshot_restore(then, now) }; + if bun_core::env_var::BUN_STARTUP_SNAPSHOT_VERBOSE + .get() + .unwrap_or(false) + { + bun_core::Output::print_errorln(format_args!( + "[snapshot] rebased {moved} timers onto this process's clock" + )); // informational, like the C++ side's verbose lines + } + } + } + // SAFETY: main-thread VM adopted; single-threaded at this point of restore. + unsafe { + (*vm_ptr) + .rare_data() + .forget_spawn_sync_event_loop_for_snapshot_restore() + }; + bun_spawn::process::WaiterThread::reset_after_snapshot_restore(); // the builder's thread is not in this process; spawn starts a new one + crate::dns_jsc::internal::flush_dns_cache_for_snapshot_restore(); // answers in the snapshot came from the builder's network + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + { + // SAFETY: main-thread VM adopted above; single-threaded at this point of restore. + let vm = unsafe { &mut *vm_ptr }; + let loop_ = vm.uws_loop(); + if let Some(store) = vm.rare_data().file_polls.as_deref_mut() { + // SAFETY: loop_ is the process-global uws loop. + let (rearmed, hung_up) = store.rearm_after_snapshot_restore(unsafe { &mut *loop_ }); + bun_core::debug_warn!( + "[snapshot] file polls: {} re-armed, {} hung up", + rearmed, + hung_up + ); + } + } +} - // These create undefined references to externally-defined C symbols - // (uv_* posix stubs, v8:: shims) so the linker pulls those archive - // members from libbun.a in CI's split link-only mode and keeps them - // through `--gc-sections`. Without them, dlopen'd NAPI modules see - // `undefined symbol: uv_*` instead of the friendly crash message. - // (Rust-defined `#[no_mangle]` exports don't need this; the imported - // C symbols do.) - crate::napi::fix_dead_code_elimination(); - crate::webcore::bake_response::fix_dead_code_elimination(); - bun_crash_handler::fix_dead_code_elimination(); - vm.global_exit(); +#[unsafe(no_mangle)] +pub extern "C" fn Bun__startupSnapshotContinueEventLoop() -> ! { + let vm_ptr = bun_jsc::virtual_machine::VirtualMachine::main_thread_vm_ptr(); + assert!(!vm_ptr.is_null(), "snapshot has no main-thread VM"); + bun_jsc::virtual_machine::VirtualMachine::adopt_on_current_thread(vm_ptr); + // SAFETY: `vm_ptr` is the snapshot's main-thread VM, now installed for this thread. + let vm = unsafe { &mut *vm_ptr }; + // SAFETY: the VM was just adopted on this thread; its event loop is the one the watch is registered on. + bun_io::ParentDeathWatchdog::reinstall_after_snapshot_restore(unsafe { + bun_jsc::virtual_machine::VirtualMachine::event_loop_ctx(vm_ptr) + }); + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + { + let pending = vm + .rare_data() + .file_polls + .as_deref_mut() + .map(bun_io::Store::take_snapshot_hangups) + .unwrap_or_default(); // the store borrow ends here; delivering may re-enter it + let n = bun_io::dispatch_snapshot_hangups(pending); + if n > 0 { + bun_core::debug_warn!( + "[snapshot] delivered {} hangups for fds that did not survive the restore", + n + ); + } } + // The 'restore' listeners just ran synchronously; continuations of anything that awaited them are microtasks and + // may be the only pending work (a snapshot taken with every timer cancelled has nothing else). Run one tick + // unconditionally so they execute and get the chance to make the loop alive again. + vm.tick(); + while vm.is_event_loop_alive() { + vm.tick(); + vm.auto_tick_active(); + } + vm.on_before_exit(); + finish_run_and_exit(vm); } #[inline] @@ -1698,7 +2152,7 @@ fn dump_build_error(vm: &mut VirtualMachine) { any(target_os = "linux", target_os = "android"), unsafe(link_section = ".text.unlikely") )] -fn exit_with_unhandled_note(vm: &mut VirtualMachine) -> ! { +pub(crate) fn exit_with_unhandled_note(vm: &mut VirtualMachine) -> ! { vm.exit_handler.exit_code = 1; vm.on_exit(); if ANY_UNHANDLED.load(Ordering::Relaxed) { @@ -1729,6 +2183,35 @@ fn entry_point_load_failed(vm: &mut VirtualMachine, err: &crate::Error) -> ! { exit_with_unhandled_note(vm); } +/// The end of every run, however it began — a loaded entry point or a restored snapshot: report what is left, let the +/// exit hooks run, turn an unhandled rejection into exit code 1, and exit. Both paths call this so they cannot drift. +fn finish_run_and_exit(vm: &mut VirtualMachine) -> ! { + if log_has_msgs(vm) { + dump_build_error(vm); + Output::flush(); + } + + vm.on_unhandled_rejection = Run::on_unhandled_rejection_before_close; + vm.global().handle_rejected_promises(); + vm.on_exit(); + + if ANY_UNHANDLED.load(Ordering::Relaxed) { + print_unhandled_version_note(vm); + } + + // These create undefined references to externally-defined C symbols + // (uv_* posix stubs, v8:: shims) so the linker pulls those archive + // members from libbun.a in CI's split link-only mode and keeps them + // through `--gc-sections`. Without them, dlopen'd NAPI modules see + // `undefined symbol: uv_*` instead of the friendly crash message. + // (Rust-defined `#[no_mangle]` exports don't need this; the imported + // C symbols do.) + crate::napi::fix_dead_code_elimination(); + crate::webcore::bake_response::fix_dead_code_elimination(); + bun_crash_handler::fix_dead_code_elimination(); + vm.global_exit(); +} + /// Cold tail of `Run::start` when `ANY_UNHANDLED` tripped on an otherwise-clean /// exit: bump the exit code and print the sourcemap note + version string. #[cold] diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 950620131d25..75c62d9762a3 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -2350,6 +2350,25 @@ pub mod internal { static GLOBAL_CACHE: bun_threading::Guarded = bun_threading::Guarded::new(GlobalCache::new()); + + /// snapshot restore: every cached answer was resolved on the builder's machine/network. Nothing is in flight at + /// restore, so entries with no waiters are freed and the table emptied; the next lookup asks this machine's resolver. + pub fn flush_dns_cache_for_snapshot_restore() { + let mut guard = global_cache().lock(); + let cache: &mut GlobalCache = &mut guard; + for i in 0..cache.len { + let e = cache.cache[i]; + // SAFETY: entries are heap `Request`s owned by the table while `GLOBAL_CACHE` is held. + unsafe { + if !e.is_null() && (*e).refcount == 0 { + Request::deinit(e); + } + } + cache.cache[i] = ptr::null_mut(); + } + cache.len = 0; + DNS_CACHE_SIZE.store(0, Ordering::Relaxed); + } #[inline] fn global_cache() -> &'static bun_threading::Guarded { &GLOBAL_CACHE @@ -3065,6 +3084,7 @@ pub mod internal { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let arguments = callframe.arguments(); if arguments.len() < 1 { @@ -4956,6 +4976,7 @@ impl Resolver { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let arguments = callframe.arguments_as_array::<3>(); let arguments_len = callframe.arguments_count() as usize; if arguments_len < 1 { @@ -5048,6 +5069,7 @@ impl Resolver { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let arguments = callframe.arguments_as_array::<2>(); let arguments_len = callframe.arguments_count() as usize; if arguments_len < 1 { @@ -5118,6 +5140,7 @@ impl Resolver { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let arguments = callframe.arguments_as_array::<2>(); let arguments_len = callframe.arguments_count() as usize; if arguments_len < 1 { @@ -5387,6 +5410,7 @@ impl Resolver { name: &[u8], global_this: &JSGlobalObject, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let channel: *mut c_ares::Channel = match self.get_channel() { ChannelResult::Result(res) => res, ChannelResult::Err(err) => { @@ -5928,6 +5952,7 @@ impl Resolver { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("dns")?; let arguments = callframe.arguments_as_array::<2>(); let arguments_len = callframe.arguments_count() as usize; if arguments_len < 2 { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60b81f6a0412..cd07343479bf 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -159,6 +159,15 @@ pub(crate) fn runtime_state() -> *mut RuntimeState { RUNTIME_STATE.with(Cell::get) } +static MAIN_THREAD_RUNTIME_STATE: core::sync::atomic::AtomicPtr = + core::sync::atomic::AtomicPtr::new(ptr::null_mut()); + +/// Snapshot: install the snapshot's main-thread RuntimeState on this thread. +pub(crate) fn adopt_main_thread_runtime_state() { + let state = MAIN_THREAD_RUNTIME_STATE.load(core::sync::atomic::Ordering::Acquire); + RUNTIME_STATE.with(|c| c.set(state)); +} + /// Recover this thread's `timer::All` heap as a raw pointer. /// /// Note: `bun_jsc::VirtualMachine.timer` is a `()` placeholder; @@ -402,6 +411,13 @@ unsafe fn init_runtime_state( wake_ctx: None, })); RUNTIME_STATE.with(|c| c.set(state)); + // Snapshot: remember the main thread's state in a plain static so a restored process can re-seat the TLS. + if MAIN_THREAD_RUNTIME_STATE + .load(core::sync::atomic::Ordering::Relaxed) + .is_null() + { + MAIN_THREAD_RUNTIME_STATE.store(state, core::sync::atomic::Ordering::Release); + } // `Timespec::now_allow_mocked_time` reads `bun_core::mock_time` directly; // `FakeTimers::CurrentTime::{set,clear}` write that storage so timers @@ -1364,6 +1380,12 @@ fn body_mixin_get_blob( Ok(None) } +/// The app (or the runtime, in auto mode) asked for a snapshot and the loop has unwound to its top: write it and exit. +fn take_snapshot(vm: *mut VirtualMachine) -> ! { + // SAFETY: main-thread VM handed over by `EventLoop::tick` at top level. + crate::cli::run_command::take_startup_snapshot_and_exit(unsafe { &mut *vm }) +} + /// `process.exit(code)`. Main-thread is `noreturn`; in a /// worker it returns and the caller `panic!`s. /// @@ -1537,6 +1559,7 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { has_blob_url, body_mixin_get_blob, process_exit, + take_snapshot, console_on_before_print, console_print_runtime_object, load_standalone_sourcemap, diff --git a/src/runtime/node/fs_events.rs b/src/runtime/node/fs_events.rs index 16159194793f..6927255b1831 100644 --- a/src/runtime/node/fs_events.rs +++ b/src/runtime/node/fs_events.rs @@ -94,8 +94,19 @@ const K_FS_EVENTS_RENAMED: c_int = K_FS_EVENT_STREAM_EVENT_FLAG_ITEM_CREATED static FSEVENTS_DEFAULT_LOOP_MUTEX: Mutex = Mutex::new(); #[cfg_attr(not(target_os = "macos"), allow(dead_code))] -static FSEVENTS_DEFAULT_LOOP: std::sync::OnceLock<&'static FSEventsLoop> = - std::sync::OnceLock::new(); +/// The process's FSEvents loop, or one left behind by a snapshot builder (its `epoch` then differs from the current one: +/// the CF thread it names never existed in this process, so `watch()` makes a fresh loop and the old one is left inert). +static FSEVENTS_DEFAULT_LOOP: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + +fn current_default_loop() -> Option<&'static FSEventsLoop> { + let p = FSEVENTS_DEFAULT_LOOP.load(Ordering::Acquire); + if p.is_null() { + return None; + } + // SAFETY: only ever set to a leaked `&'static FSEventsLoop`. + let l: &'static FSEventsLoop = unsafe { &*p }; + (l.epoch == bun_core::startup_snapshot::epoch()).then_some(l) +} #[cfg(unix)] fn dlsym(handle: *mut c_void, symbol: &core::ffi::CStr) -> Option { @@ -275,6 +286,8 @@ fn init_core_services() -> CoreServices { } pub struct FSEventsLoop { + /// `bun_core::startup_snapshot::epoch()` when this loop (and its CF thread) was created. + epoch: u32, signal_source: AtomicPtr, loop_: AtomicPtr, mutex: Mutex, @@ -418,6 +431,7 @@ impl FSEventsLoop { // Owning raw pointer first, shared view second: the error paths below reclaim // through `this_ptr`, which must not be derived from a shared reference. let this_ptr: *mut FSEventsLoop = bun_core::heap::into_raw(Box::new(FSEventsLoop { + epoch: bun_core::startup_snapshot::epoch(), signal_source: AtomicPtr::new(ptr::null_mut()), loop_: AtomicPtr::new(ptr::null_mut()), mutex: Mutex::new(), @@ -489,6 +503,9 @@ impl FSEventsLoop { } fn enqueue_task_concurrent(&self, task: Task) { + if self.epoch != bun_core::startup_snapshot::epoch() { + return; // this loop's CF thread belonged to the process that built the snapshot + } let cf = CoreFoundation::get(); let concurrent = bun_core::heap::into_raw(Box::new(ConcurrentTask { task: Task { @@ -918,17 +935,20 @@ pub(crate) fn watch( update_end: UpdateEndCallback, ctx: *mut c_void, ) -> crate::Result> { - if let Some(&loop_) = FSEVENTS_DEFAULT_LOOP.get() { + if let Some(loop_) = current_default_loop() { return Ok(FSEventsWatcher::init( loop_, path, recursive, callback, update_end, ctx, )); } let _guard = FSEVENTS_DEFAULT_LOOP_MUTEX.lock_guard(); - let loop_: &'static FSEventsLoop = match FSEVENTS_DEFAULT_LOOP.get() { - Some(&l) => l, + let loop_: &'static FSEventsLoop = match current_default_loop() { + Some(l) => l, None => { let l = FSEventsLoop::init()?; - let _ = FSEVENTS_DEFAULT_LOOP.set(l); + FSEVENTS_DEFAULT_LOOP.store( + core::ptr::from_ref::(l).cast_mut(), + Ordering::Release, + ); bun_core::Global::add_pre_exit_callback(close_and_wait_on_exit); l } @@ -938,13 +958,19 @@ pub(crate) fn watch( )) } +/// snapshot build: join the CF thread before memory is frozen; the restored process makes a new loop on its first `watch()`. +#[cfg(target_os = "macos")] +pub(crate) fn shutdown_for_snapshot() { + close_and_wait(); +} + extern "C" fn close_and_wait_on_exit() { close_and_wait() } fn close_and_wait() { #[cfg(target_os = "macos")] - if let Some(&loop_) = FSEVENTS_DEFAULT_LOOP.get() { + if let Some(loop_) = current_default_loop() { let _guard = FSEVENTS_DEFAULT_LOOP_MUTEX.lock_guard(); loop_.shutdown(); } diff --git a/src/runtime/node/node_fs_binding.rs b/src/runtime/node/node_fs_binding.rs index 99415ff3c126..3356b44a9d3d 100644 --- a/src/runtime/node/node_fs_binding.rs +++ b/src/runtime/node/node_fs_binding.rs @@ -23,6 +23,7 @@ pub(crate) type NodeFSFunction = /// Async calls use a thread pool. /// `Bindings(FunctionEnum).runSync`. + fn run_sync( this: &Binding, global: &JSGlobalObject, @@ -33,6 +34,7 @@ where { // SAFETY: `bun_vm()` returns the live `*mut VirtualMachine`; borrowed only // for the duration of argument parsing on the JS thread. + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; let vm: &VirtualMachine = global.bun_vm(); let mut slice = ArgumentsSlice::init(vm, frame.arguments()); // `defer slice.deinit()` → `Drop for ArgumentsSlice`. @@ -71,6 +73,7 @@ fn run_async( frame: &CallFrame, create_task: fn(&JSGlobalObject, &Binding, A, &mut VirtualMachine) -> JSValue, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // SAFETY: JS-thread borrow of the per-thread VM; outlives `slice`. let vm: &mut VirtualMachine = global.bun_vm().as_mut(); let mut slice = ManuallyDrop::new(ArgumentsSlice::init(vm, frame.arguments())); @@ -184,6 +187,7 @@ impl Binding { /// `callAsync(.cp)` — `AsyncCpTask::create` copies its paths via /// `to_thread_safe()`, so the arena is dropped with `slice`. pub(crate) fn cp(this: &Self, global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // the macro-generated ops are gated in run_sync/run_async; these are outside it // SAFETY: JS-thread borrow of the per-thread VM; outlives `slice`. let vm: &mut VirtualMachine = global.bun_vm().as_mut(); let mut slice = ManuallyDrop::new(ArgumentsSlice::init(vm, frame.arguments())); @@ -216,6 +220,7 @@ impl Binding { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // gated here because it is outside the macro-generated ops // SAFETY: JS-thread borrow of the per-thread VM. let vm: &VirtualMachine = global.bun_vm(); let mut slice = ArgumentsSlice::init(vm, frame.arguments()); @@ -241,6 +246,7 @@ impl Binding { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // gated here because it is outside the macro-generated ops // SAFETY: JS-thread borrow of the per-thread VM; outlives `slice`. let vm: &mut VirtualMachine = global.bun_vm().as_mut(); let mut slice = ManuallyDrop::new(ArgumentsSlice::init(vm, frame.arguments())); @@ -280,6 +286,7 @@ impl Binding { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // gated here because it is outside the macro-generated ops // SAFETY: JS-thread borrow of the per-thread VM. let vm: &VirtualMachine = global.bun_vm(); let mut slice = ArgumentsSlice::init(vm, frame.arguments()); @@ -307,6 +314,7 @@ impl Binding { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // gated here because it is outside the macro-generated ops // SAFETY: JS-thread borrow of the per-thread VM. let vm: &VirtualMachine = global.bun_vm(); let mut slice = ArgumentsSlice::init(vm, frame.arguments()); @@ -332,6 +340,7 @@ impl Binding { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("node:fs")?; // gated here because it is outside the macro-generated ops // SAFETY: JS-thread borrow of the per-thread VM. let vm: &VirtualMachine = global.bun_vm(); let _slice = ArgumentsSlice::init(vm, frame.arguments()); diff --git a/src/runtime/node/node_fs_stat_watcher.rs b/src/runtime/node/node_fs_stat_watcher.rs index e5c13c53bf5d..bbfa184393a4 100644 --- a/src/runtime/node/node_fs_stat_watcher.rs +++ b/src/runtime/node/node_fs_stat_watcher.rs @@ -610,6 +610,16 @@ impl StatWatcher { self.global_this.get() } + /// Snapshot restore: a scheduler the builder created compares against the builder's main thread otherwise. + pub(crate) fn readopt_main_thread_after_snapshot_restore(vm: &mut VirtualMachine) { + if let Some(p) = *vm.rare_data().node_fs_stat_watcher_scheduler_slot() { + // SAFETY: the slot holds a live scheduler owned by this VM; restore is single-threaded, so nothing reads the field concurrently. + unsafe { + (*p.as_ptr().cast::()).main_thread = thread::current().id() + }; + } + } + /// Spec `RareData.nodeFSStatWatcherScheduler`. Body lives here (high tier) /// because `StatWatcherScheduler` cannot be named from `bun_jsc::rare_data` /// without a crate cycle; the slot in `RareData` is an erased diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index d021b241ea51..37aace84e700 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -27,6 +27,17 @@ extern "C" fn create_argv0(global_object: &JSGlobalObject) -> JSValue { ZigString::from_utf8(argv0).to_js(global_object) } +/// The script's own arguments. When the CLI recorded where they start in argv (compiled executables and the plain +/// `bun entry a b` shape), they are read from the live process argv — recomputed after a snapshot restore — +/// otherwise from the CLI's parsed passthrough. Entries borrow process-lifetime storage. +pub(crate) fn passthrough_argv(vm: &bun_jsc::virtual_machine::VirtualMachine) -> Vec<&[u8]> { + let offset = bun_core::standalone_passthrough_offset(); + if offset > 0 && vm.worker_ref().is_none() { + return bun_core::argv().iter().skip(offset).collect(); + } + vm.argv.iter().map(|a| &a[..]).collect() +} + #[unsafe(export_name = "Bun__Process__getExecPath")] extern "C" fn get_exec_path(global_object: &JSGlobalObject) -> JSValue { let Ok(out) = bun_core::self_exe_path() else { @@ -394,7 +405,7 @@ mod _impl { let args_count: usize = match worker { Some(w) => w.argv().len(), - None => vm.argv.len(), + None => super::passthrough_argv(vm).len(), }; // argv omits "bun" because it could be "bun run" or "bun" and it's kind of ambiguous @@ -444,7 +455,7 @@ mod _impl { .collect(); args_list.extend(worker_args.iter().map(|s| **s)); } else { - for arg in &vm.argv { + for arg in super::passthrough_argv(vm) { let str_ = BunString::borrow_utf8(arg); // https://github.com/yargs/yargs/blob/adb0d11e02c613af3d9427b3028cc192703a3869/lib/utils/process-argv.ts#L1 args_list.push(str_); diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 715150032bac..e8f67bc7e6b7 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -69,10 +69,38 @@ bun_output::define_scoped_log!(log, fs_watch, hidden); // `Platform::init` can be retried on a later `get()` without two threads // racing to allocate; `OnceLock` provides the Acquire/Release publish so the // FSEvents-thread reads in `on_fs_event` need no `unsafe`. -static DEFAULT_MANAGER: std::sync::OnceLock<&'static PathWatcherManager> = - std::sync::OnceLock::new(); +// A manager made by the process that built a startup snapshot owns that process's inotify/kqueue fd and reader thread; +// after a restore it is left behind (never freed: detached watchers may still point at it) and a new one is made. +static DEFAULT_MANAGER: core::sync::atomic::AtomicPtr = + core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); +static DEFAULT_MANAGER_EPOCH: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); static DEFAULT_MANAGER_MUTEX: Mutex = Mutex::new(); +/// Set when the reader thread returns, however it returns; the freeze-time shutdown waits on it. +#[cfg(any(target_os = "linux", target_os = "android"))] +struct MarkExited(&'static PathWatcherManager); +#[cfg(any(target_os = "linux", target_os = "android"))] +impl Drop for MarkExited { + fn drop(&mut self) { + self.0 + .thread_exited + .store(true, core::sync::atomic::Ordering::Release); + } +} + +/// The manager belonging to this process, if one has been made since the last restore. +fn current_manager() -> Option<&'static PathWatcherManager> { + use core::sync::atomic::Ordering; + let m = DEFAULT_MANAGER.load(Ordering::Acquire); + if m.is_null() + || DEFAULT_MANAGER_EPOCH.load(Ordering::Acquire) != bun_core::startup_snapshot::epoch() + { + return None; + } + // SAFETY: only ever set to a leaked `&'static PathWatcherManager`. + Some(unsafe { &*m }) +} + // ──────────────────────────────────────────────────────────────────────────────── // PathWatcherManager // ──────────────────────────────────────────────────────────────────────────────── @@ -109,6 +137,8 @@ pub(crate) struct PathWatcherManager { /// Reader-thread loop flag. Initialized `true`, never cleared (no teardown). #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] running: AtomicBool, + #[cfg(any(target_os = "linux", target_os = "android"))] + thread_exited: core::sync::atomic::AtomicBool, /// Monotonic kevent generation counter (FreeBSD). Bumped under `mutex`. /// `Cell` so the bump is a safe `.get()/.set()` instead of a raw deref. @@ -139,6 +169,8 @@ impl Default for PathWatcherManager { platform_fd: Cell::new(Fd::INVALID), #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] running: AtomicBool::new(true), + #[cfg(any(target_os = "linux", target_os = "android"))] + thread_exited: core::sync::atomic::AtomicBool::new(false), #[cfg(target_os = "freebsd")] next_gen: Cell::new(1), } @@ -152,15 +184,18 @@ impl PathWatcherManager { // on ARM64 could observe the non-null pointer before `m.* = .{}` is visible and // lock a garbage `m.mutex`). `get()` runs once per `fs.watch()` call; the mutex is // uncontended after initialization. + use core::sync::atomic::Ordering; let _g = DEFAULT_MANAGER_MUTEX.lock_guard(); - if let Some(&m) = DEFAULT_MANAGER.get() { + if let Some(m) = current_manager() { return Ok(m); } let m = Platform::init()?; - // Holding DEFAULT_MANAGER_MUTEX with `.get()` having returned `None` - // above, so this is the first publish; `set` cannot fail. - let _ = DEFAULT_MANAGER.set(m); + DEFAULT_MANAGER.store( + core::ptr::from_ref::(m).cast_mut(), + Ordering::Release, + ); + DEFAULT_MANAGER_EPOCH.store(bun_core::startup_snapshot::epoch(), Ordering::Release); Ok(m) } @@ -872,6 +907,7 @@ impl Linux { } fn thread_main(manager: &'static PathWatcherManager) { + let _exited = MarkExited(manager); use bun_sys::linux::IN; Output::Source::configure_named_thread(zstr!("fs.watch")); let plat: *mut Linux = manager.platform.get(); @@ -1299,7 +1335,7 @@ impl Darwin { // watcher already unlinked. Forming a reference here before that check would // alias detach's access; raw-ptr reads have no exclusivity assertion. let watcher_ptr = ctx.cast::(); - let Some(&manager) = DEFAULT_MANAGER.get() else { + let Some(manager) = current_manager() else { return; }; let _g = manager.mutex.lock_guard(); @@ -1321,7 +1357,7 @@ impl Darwin { fn on_fs_event_flush(ctx: *mut c_void) { // SAFETY: see on_fs_event — keep raw until locked + manager-is-none checked. let watcher_ptr = ctx.cast::(); - let Some(&manager) = DEFAULT_MANAGER.get() else { + let Some(manager) = current_manager() else { return; }; let _g = manager.mutex.lock_guard(); @@ -1619,3 +1655,34 @@ impl Kqueue { // ──────────────────────────────────────────────────────────────────────────────── // Windows stub // ──────────────────────────────────────────────────────────────────────────────── + +/// Before a snapshot is frozen: no thread of ours may exist. The inotify reader blocks in `read`; removing a watch makes it +/// return, `running` makes it leave, and the freeze waits (bounded) for it to be gone. The restored process gets a fresh +/// manager for its epoch (`current_manager`), so nothing here needs undoing there. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub fn shutdown_for_snapshot() -> bool { + use core::sync::atomic::Ordering; + let Some(manager) = current_manager() else { + return true; + }; + if manager.thread_exited.load(Ordering::Acquire) { + return true; + } + manager.running.store(false, Ordering::Release); + let fd = manager.inotify_fd().native(); + // SAFETY: plain inotify calls on our own descriptor; "/" always exists. The add/remove pair exists only to produce an event. + unsafe { + let wd = libc::inotify_add_watch(fd, c"/".as_ptr(), libc::IN_DELETE_SELF as u32); + if wd >= 0 { + libc::inotify_rm_watch(fd, wd as _); // the descriptor is i32 on glibc/musl and u32 on bionic + } + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while !manager.thread_exited.load(Ordering::Acquire) { + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::yield_now(); + } + true +} diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 564d7dc4c855..2e3e0d3ce673 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2761,6 +2761,14 @@ impl NewServer { // `global_this()` returns a borrow of the separate STATIC allocation, // not `*this`. let global = this_ref.global_this(); + if global + .throw_disabled_in_snapshot_error_if_needed("Bun.serve") + .is_err() + { + // SAFETY: caller contract — `this` is the live boxed server from `init()`; freed here like every other failure below. + Self::deinit(this); + return JSValue::ZERO; // thrown; every server (node:http included) listens through here + } let app: *mut uws_sys::NewApp; let route_list_value; diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index d1c3a18fe91a..9826e0f703b9 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -169,6 +169,7 @@ impl Listener { // Note: no #[bun_jsc::host_fn] — BunObject.rs::static_adapters owns the // C-ABI shim (it extracts `opts` from the CallFrame and calls this directly). pub(crate) fn listen(global: &JSGlobalObject, opts: JSValue) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.listen")?; log!("listen"); if opts.is_empty_or_undefined_or_null() || opts.is_boolean() || !opts.is_object() { return Err(global.throw_invalid_arguments(format_args!("Expected object"))); @@ -1064,6 +1065,7 @@ impl Listener { if opts.is_empty_or_undefined_or_null() || opts.is_boolean() || !opts.is_object() { return Err(global.throw_invalid_arguments(format_args!("Expected options object"))); } + global.throw_disabled_in_snapshot_error_if_needed("Bun.connect")?; let vm = VirtualMachine::get().as_mut(); // Client mode: these handlers have no owning listener, so diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 8c8b8367f18e..5054e07bf67d 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -568,6 +568,7 @@ impl UDPSocket { } pub(crate) fn udp_socket(global_this: &JSGlobalObject, options: JSValue) -> JsResult { + global_this.throw_disabled_in_snapshot_error_if_needed("Bun.udpSocket")?; // node:dgram arrives here too bun_output::scoped_log!(UdpSocket, "udpSocket"); let this_ptr = Self::new(Self { diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index f6cb91c51568..c07c964dcc0d 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -1228,6 +1228,38 @@ impl All { } } + /// Timers armed before a heap snapshot carry absolute CLOCK_MONOTONIC deadlines of the building process; shift them by (now - then). + pub(crate) unsafe fn rebase_after_snapshot_restore( + &mut self, + then: bun_core::Timespec, + now: bun_core::Timespec, + ) -> usize { + self.thread_id = std::thread::current().id(); // the builder's main thread's id, until now + let delta_ns: i128 = (now.sec as i128 - then.sec as i128) * 1_000_000_000 + + (now.nsec as i128 - then.nsec as i128); + if then.sec == 0 && then.nsec == 0 { + return 0; + } + let mut nodes: Vec<*mut EventLoopTimer> = Vec::new(); + while let Some(min) = self.timers.peek() { + // SAFETY: live heap node; removed before mutation of its key. + unsafe { self.timers.remove(min) }; + nodes.push(min); + } + let moved = nodes.len(); + for t in nodes { + // SAFETY: node was just removed from the heap and is otherwise owned by its TimerObject. + unsafe { + let cur: i128 = ((*t).next.sec as i128) * 1_000_000_000 + ((*t).next.nsec as i128); + let v = cur + delta_ns; + (*t).next.sec = (v.div_euclid(1_000_000_000)) as _; + (*t).next.nsec = (v.rem_euclid(1_000_000_000)) as _; + self.timers.insert(t); + } + } + moved + } + /// VM-teardown pass: `cancel()` every `TimeoutObject` / `ImmediateObject` /// still linked in `timers` / `fake_timers.timers` so the in-heap `+1` ref /// and the JS pin (`this_value` Strong) are released before the GC sweep. diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 3e9b5efb447a..e890d3fe7a1c 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -446,6 +446,14 @@ impl BlobExt for Blob { fn do_read_file(&self, global: &JSGlobalObject) -> JSValue { debug!("doReadFile"); + if let Some(kind) = self.snapshot_io_kind() + && let Err(e) = global.throw_disabled_in_snapshot_error_if_needed(kind) + { + let err = global.take_error(e); + return JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global, err, + ); + } type Handler<'a, F> = read_file::NewReadFileHandler<'a, F>; @@ -1171,6 +1179,9 @@ impl BlobExt for Blob { Ok(()) } fn get_stream(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(kind) = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } self.get_stream_with_cache( global_this, callframe, @@ -1223,6 +1234,9 @@ impl BlobExt for Blob { Ok(stream) } fn get_text(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind @ "Bun.s3") = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; // file-backed reads are gated (and recorded once) in do_read_file + } Ok(self.get_text_clone(global_this)?) } @@ -1232,6 +1246,9 @@ impl BlobExt for Blob { } fn get_json(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind @ "Bun.s3") = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; // file-backed reads are gated (and recorded once) in do_read_file + } Ok(self.get_json_share(global_this)?) } @@ -1249,6 +1266,9 @@ impl BlobExt for Blob { } fn get_array_buffer(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind @ "Bun.s3") = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; // file-backed reads are gated (and recorded once) in do_read_file + } Ok(self.get_array_buffer_clone(global_this)?) } @@ -1258,10 +1278,16 @@ impl BlobExt for Blob { } fn get_bytes(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind @ "Bun.s3") = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; // file-backed reads are gated (and recorded once) in do_read_file + } Ok(self.get_bytes_clone(global_this)?) } fn get_form_data(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind @ "Bun.s3") = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; // file-backed reads are gated (and recorded once) in do_read_file + } let _store = self.store.get().clone(); Ok(JSPromise::wrap(global_this, |g| { self.to_form_data(g, Lifetime::Temporary) @@ -1363,6 +1389,9 @@ impl BlobExt for Blob { } fn do_unlink(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(kind) = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } // SAFETY: bun_vm() never returns null for a Bun-owned global. let mut args = jsc::ArgumentsSlice::init(global_this.bun_vm(), callframe.arguments()); @@ -1378,6 +1407,9 @@ impl BlobExt for Blob { // This mostly means 'can it be read?' fn get_exists(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + if let Some(kind) = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } if self.is_s3() { return crate::webcore::s3_file::S3BlobStatTask::exists(global_this, self); } @@ -1689,6 +1721,9 @@ impl BlobExt for Blob { } fn get_writer(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(kind) = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } let [arg0] = callframe.arguments_as_array::<1>(); let has_args = callframe.arguments_count() > 0; @@ -2189,6 +2224,9 @@ impl BlobExt for Blob { self.size.get() } fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult { + if let Some(kind) = self.snapshot_io_kind() { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } // TODO: make this async for files let tag = match self.store.get() { None => return Ok(JSValue::UNDEFINED), @@ -5327,6 +5365,15 @@ pub(crate) fn write_file(global_this: &JSGlobalObject, callframe: &CallFrame) -> // accept a path or a blob // `defer if (.path) path.deinit()` → `Drop for PathLike` (via PathOrBlob). let mut path_or_blob = PathOrBlob::from_js_no_copy(global_this, &mut args)?; + let gate_kind = match &path_or_blob { + PathOrBlob::Path(_) => Some("Bun.write"), + PathOrBlob::Blob(blob) => blob + .snapshot_io_kind() + .map(|k| if k == "Bun.file" { "Bun.write" } else { k }), // stdio stays writable; an S3 destination is network I/O + }; + if let Some(kind) = gate_kind { + global_this.throw_disabled_in_snapshot_error_if_needed(kind)?; + } // "Blob" must actually be a BunFile, not a webcore blob. if let PathOrBlob::Blob(ref blob) = path_or_blob { validate_writable_blob(global_this, blob)?; diff --git a/src/runtime/webcore/S3Client.rs b/src/runtime/webcore/S3Client.rs index 73b614a90bce..f73faa3728e9 100644 --- a/src/runtime/webcore/S3Client.rs +++ b/src/runtime/webcore/S3Client.rs @@ -429,6 +429,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); @@ -469,6 +470,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); @@ -509,6 +511,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); @@ -549,6 +552,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); @@ -604,6 +608,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; let args = callframe.arguments_as_array::<2>(); let object_keys = args[0]; @@ -634,6 +639,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); @@ -722,6 +728,7 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; let args = callframe.arguments_as_array::<2>(); let object_keys = args[0]; let options = opt_js(args[1]); diff --git a/src/runtime/webcore/S3File.rs b/src/runtime/webcore/S3File.rs index 8fd5489395a7..badcd7426bf0 100644 --- a/src/runtime/webcore/S3File.rs +++ b/src/runtime/webcore/S3File.rs @@ -144,6 +144,7 @@ pub(crate) fn presign(global: &JSGlobalObject, callframe: &CallFrame) -> JsResul #[bun_jsc::host_fn] pub(crate) fn unlink(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: bun_vm() returns the live VM raw ptr. let mut args = bun_jsc::call_frame::ArgumentsSlice::init(global.bun_vm(), callframe.arguments()); @@ -185,6 +186,7 @@ pub(crate) fn unlink(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult #[bun_jsc::host_fn] pub fn write(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: bun_vm() returns the live VM raw ptr. let mut args = bun_jsc::call_frame::ArgumentsSlice::init(global.bun_vm(), callframe.arguments()); @@ -255,6 +257,7 @@ pub fn write(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: bun_vm() returns the live VM raw ptr. let mut args = bun_jsc::call_frame::ArgumentsSlice::init(global.bun_vm(), callframe.arguments()); @@ -292,6 +295,7 @@ pub(crate) fn size(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: bun_vm() returns the live VM raw ptr. let mut args = bun_jsc::call_frame::ArgumentsSlice::init(global.bun_vm(), callframe.arguments()); @@ -799,11 +803,13 @@ pub(crate) fn get_stat( global: &JSGlobalObject, _callframe: &CallFrame, ) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; S3BlobStatTask::stat(global, this) } #[bun_jsc::host_fn] pub(crate) fn stat(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + global.throw_disabled_in_snapshot_error_if_needed("Bun.s3")?; // SAFETY: bun_vm() returns the live VM raw ptr. let mut args = bun_jsc::call_frame::ArgumentsSlice::init(global.bun_vm(), callframe.arguments()); diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 2a41b66e2cc4..553bbc99277e 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1193,6 +1193,28 @@ fn fetch_impl( return Ok(JSValue::ZERO); } + if bun_core::startup_snapshot::building() + && url_type == URLType::Remote + && global_this + .throw_disabled_in_snapshot_error_if_needed("fetch") + .is_err() + { + // Refused: the gate left its error thrown; a fetch() rejects instead, before any tasklet, body stream or listener + // is created. The caller's signal is theirs: a rejected fetch never aborts it. + global_this.clear_exception(); + if let Some(sig) = signal.take() { + // SAFETY: `sig` came from `AbortSignal::ref_()` above and is not otherwise retained on this path. + unsafe { (*sig).unref() }; + } + let err = global_this.to_type_error(jsc::ErrorCode::INVALID_STATE, format_args!("fetch() to the network is not available while building a snapshot: its result would be frozen into every launch. Do it after restore (process.on('restore'))")); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err, + ), + ); + } + // We do this 2nd to last instead of last so that if it's a FormData // object, we can still insert the boundary. // diff --git a/src/spawn/process.rs b/src/spawn/process.rs index dadd2fbb871b..6951b88bffc0 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -937,6 +937,9 @@ pub mod waiter_thread_posix { pub struct WaiterThreadPosix { pub(crate) started: AtomicU32, + /// Snapshot: asks the loop to return (Linux, where it can be woken) / set by the loop once it has. + pub(crate) stop_requested: core::sync::atomic::AtomicBool, + pub(crate) exited: core::sync::atomic::AtomicBool, #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) eventfd: Fd, pub(crate) js_process: ProcessQueue, @@ -1248,6 +1251,8 @@ pub mod waiter_thread_posix { unsafe impl Sync for Instance {} static INSTANCE: Instance = Instance(core::cell::UnsafeCell::new(WaiterThreadPosix { started: AtomicU32::new(0), + stop_requested: core::sync::atomic::AtomicBool::new(false), + exited: core::sync::atomic::AtomicBool::new(false), #[cfg(any(target_os = "linux", target_os = "android"))] eventfd: Fd::INVALID, js_process: ProcessQueue::new(), @@ -1285,6 +1290,54 @@ pub mod waiter_thread_posix { bun_spawn_sys::waiter_thread_flag::get() } + pub fn is_running() -> bool { + let this = instance_ref(); + this.started.load(Ordering::Acquire) != 0 && !this.exited.load(Ordering::Acquire) + } + + /// Where the thread cannot be stopped, a running one is something the app has to avoid before taking a snapshot. + pub fn snapshot_blocker() -> Option<&'static str> { + if cfg!(any(target_os = "linux", target_os = "android")) || !Self::is_running() { + return None; + } + Some( + "the subprocess waiter thread is running (BUN_FEATURE_FLAG_FORCE_WAITER_THREAD) — a snapshot cannot contain a thread; build without that flag", + ) + } + + /// Snapshot freeze: `Ok(true)` if none is running or it stopped; `Ok(false)` if it did not stop in time; `Err(())` where a + /// running one cannot be stopped (non-Linux, where it only exists when forced by a feature flag). + pub fn stop_for_snapshot() -> Result { + let this = instance_ref(); + if this.started.load(Ordering::Acquire) == 0 || this.exited.load(Ordering::Acquire) { + return Ok(true); + } + #[cfg(any(target_os = "linux", target_os = "android"))] + { + this.stop_requested.store(true, Ordering::Release); + let one: [u8; 8] = (1usize).to_ne_bytes(); + let _ = bun_sys::write(this.eventfd, &one); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while !this.exited.load(Ordering::Acquire) { + if std::time::Instant::now() >= deadline { + return Ok(false); + } + std::thread::yield_now(); + } + Ok(true) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + Err(()) + } + + /// Snapshot restore: the builder's thread and eventfd do not exist here; the next spawn starts a fresh one. + pub fn reset_after_snapshot_restore() { + let this = instance_ref(); + this.stop_requested.store(false, Ordering::Relaxed); + this.exited.store(false, Ordering::Relaxed); + this.started.store(0, Ordering::Release); + } + pub(crate) fn append(process: *mut Process) { // `js_process.queue` is an MPSC lock-free queue; `append` is the // producer half and only touches `queue`, never `active`. @@ -1379,11 +1432,21 @@ pub mod waiter_thread_posix { // (aliased-&mut). A shared `&'static` is fine — see `instance_ref()`. let this: &'static WaiterThreadPosix = instance_ref(); + struct MarkExited; + impl Drop for MarkExited { + fn drop(&mut self) { + instance_ref().exited.store(true, Ordering::Release); + } + } + let _exited = MarkExited; #[allow(unused_labels)] 'outer: loop { // `loop_` takes `&self`; coexists soundly with producer `&NewQueue` // in `append()` (interior mutability via `active: UnsafeCell`). this.js_process.loop_(); + if this.stop_requested.load(Ordering::Acquire) { + return; + } #[cfg(any(target_os = "linux", target_os = "android"))] { @@ -1428,6 +1491,16 @@ pub enum WaiterThread {} #[cfg(not(unix))] impl WaiterThread { pub fn set_should_use_waiter_thread() {} + pub fn is_running() -> bool { + false + } + pub fn snapshot_blocker() -> Option<&'static str> { + None + } + pub fn stop_for_snapshot() -> Result { + Ok(true) + } + pub fn reset_after_snapshot_restore() {} } // (PosixSpawnOptions / StdioKind / Dup2 / PosixStdio moved to bun_spawn_sys — diff --git a/src/spawn_sys/lib.rs b/src/spawn_sys/lib.rs index 397ec3a59584..413d3ae5c649 100644 --- a/src/spawn_sys/lib.rs +++ b/src/spawn_sys/lib.rs @@ -142,18 +142,20 @@ pub mod waiter_thread_flag { // on `worker.terminate()`). // ────────────────────────────────────────────────────────────────────────── pub mod pdeathsig { + use core::cell::Cell; use core::sync::atomic::{AtomicBool, Ordering}; - use std::sync::OnceLock; - use std::thread::ThreadId; static DEFAULT_PDEATHSIG_ON_LINUX: AtomicBool = AtomicBool::new(false); - static INSTALL_THREAD: OnceLock = OnceLock::new(); + thread_local! { + /// Set on the thread that armed the default; thread-local so a process restored from a snapshot (fresh TLS) has to re-arm via `readopt_arming_thread`. + static ARMING_THREAD: Cell = const { Cell::new(false) }; + } /// Arm the default. Records the calling thread so `should_default` only /// returns `true` for spawns issued from that thread. Idempotent. pub fn set_default(enabled: bool) { if enabled { - let _ = INSTALL_THREAD.set(std::thread::current().id()); + ARMING_THREAD.set(true); } DEFAULT_PDEATHSIG_ON_LINUX.store(enabled, Ordering::Release); } @@ -172,7 +174,12 @@ pub mod pdeathsig { /// race the process-wide subreaper flag and reap each other's children. #[inline] pub fn is_arming_thread() -> bool { - INSTALL_THREAD.get().copied() == Some(std::thread::current().id()) + ARMING_THREAD.get() + } + + /// The main thread of a process restored from a snapshot calls this: the builder armed on its own main thread, whose TLS did not come along. + pub fn readopt_arming_thread() { + ARMING_THREAD.set(true); } } diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 6f427e1e48da..0d23159e9466 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -45,6 +45,8 @@ pub struct StandaloneModuleGraph { pub entry_point_id: u32, pub compile_exec_argv: &'static [u8], pub flags: Flags, + /// Embedded snapshot (`--snapshot`): pointer into the mapped section + length; `(null, 0)` when absent. + pub snapshot: (*const u8, usize), } // We never want to hit the filesystem for these files @@ -608,20 +610,45 @@ pub(crate) struct Offsets { pub entry_point_id: u32, pub compile_exec_argv_ptr: StringPointer, pub flags: Flags, + /// `--snapshot`: a raw, page-aligned snapshot embedded after the modules (`{0,0}` when absent). Restore maps regions straight from the executable. + pub snapshot: StringPointer, } +// `bun_startup_snapshot_placement_wanted` in c-bindings.cpp reads `flags` and `snapshot.length` out of this struct before main (the +// allocator asks it whether to place deterministically); these pin the numbers it uses, so a layout change fails to build here. +const _: () = { + assert!(size_of::() == 40); + assert!(core::mem::offset_of!(Offsets, flags) == 28); + assert!( + core::mem::offset_of!(Offsets, snapshot) + core::mem::offset_of!(StringPointer, length) + == 36 + ); + assert!(Flags::TAKE_STARTUP_SNAPSHOT.bits() == 1 << 4); +}; bitflags::bitflags! { #[repr(transparent)] - #[derive(Clone, Copy, Default)] + #[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct Flags: u32 { const DISABLE_DEFAULT_ENV_FILES = 1 << 0; const DISABLE_AUTOLOAD_BUNFIG = 1 << 1; const DISABLE_AUTOLOAD_TSCONFIG = 1 << 2; const DISABLE_AUTOLOAD_PACKAGE_JSON = 1 << 3; - // _padding: u28 + /// Stamped by `bun build --snapshot` into the executable it is about to run: that run writes `.snapshot` instead of starting the app; embedding clears them. + const TAKE_STARTUP_SNAPSHOT = 1 << 4; + const STARTUP_SNAPSHOT_MANUAL = 1 << 5; + const STARTUP_SNAPSHOT_IO_LOCAL = 1 << 6; + const STARTUP_SNAPSHOT_IO_NETWORK = 1 << 7; + // _padding: u24 } } +impl Flags { + pub const STARTUP_SNAPSHOT_BUILD_BITS: Flags = Flags::TAKE_STARTUP_SNAPSHOT + .union(Flags::STARTUP_SNAPSHOT_MANUAL) + .union(Flags::STARTUP_SNAPSHOT_IO_LOCAL) + .union(Flags::STARTUP_SNAPSHOT_IO_NETWORK); +} + const TRAILER: &[u8] = b"\n---- Bun! ----\n"; impl StandaloneModuleGraph { @@ -638,6 +665,7 @@ impl StandaloneModuleGraph { entry_point_id: 0, compile_exec_argv: b"", flags: Flags::default(), + snapshot: (core::ptr::null(), 0), }); } @@ -762,10 +790,46 @@ impl StandaloneModuleGraph { } .as_bytes(), flags: offsets.flags, + snapshot: { + let ptr = offsets.snapshot; + let (off, len) = (ptr.offset as usize, ptr.length as usize); + if len != 0 && off.checked_add(len).is_some_and(|end| end <= raw_len) { + // SAFETY: subrange of the mapped section, verified above; read-only. + (unsafe { raw_const.add(off) }, len) + } else { + (core::ptr::null(), 0) + } + }, }) } } +/// For the restore path: whether this executable carries an embedded snapshot and where it is mapped (false if not compiled or none embedded). +/// +/// # Safety +/// `out_ptr` and `out_len` must be valid for writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__standaloneEmbeddedStartupSnapshot( + out_ptr: *mut *const u8, + out_len: *mut usize, +) -> bool { + let Some((ptr, len)) = StandaloneModuleGraph::embedded_startup_snapshot_early() else { + return false; + }; + // SAFETY: per the function contract. + unsafe { + *out_ptr = ptr; + *out_len = len; + } + true +} + +/// Bits: 1 = this run takes the snapshot, 2 = manual (the app calls take()), 4 = local I/O allowed, 8 = network allowed. +#[unsafe(no_mangle)] +pub extern "C" fn Bun__standaloneStartupSnapshotBuildFlags() -> u32 { + StandaloneModuleGraph::startup_snapshot_build_flags_early().bits() >> 4 +} + /// Read-only subslice helper. Builds a `&'static [u8]` over the *subrange only* so no /// shared reference ever spans the writable bytecode/module_info regions of the same /// allocation (which would be invalidated by JSC's in-place writes). @@ -814,12 +878,16 @@ unsafe fn slice_to_z(base: *const u8, len: usize, ptr: StringPointer) -> &'stati unsafe { ZStr::from_raw(base.add(off), n) } } +/// Page alignment for an embedded snapshot inside the section payload (arm64 pages; also a multiple of x86-64's 4 KiB). +pub const EMBEDDED_SNAPSHOT_ALIGN: usize = 16 * 1024; + pub(crate) fn to_bytes( prefix: &[u8], output_files: &[OutputFile], output_format: Format, compile_exec_argv: &[u8], flags: Flags, + snapshot: Option<&[u8]>, ) -> crate::Result> { // RAII trace handle ends on drop. let _serialize_trace = bun_perf::trace(bun_perf::PerfEvent::StandaloneModuleGraphSerialize); @@ -867,6 +935,9 @@ pub(crate) fn to_bytes( string_builder.cap += 16; string_builder.cap += size_of::(); string_builder.count_z(compile_exec_argv); + if let Some(img) = snapshot { + string_builder.cap += EMBEDDED_SNAPSHOT_ALIGN + img.len(); // padding to a page boundary + the snapshot + } string_builder.allocate()?; @@ -1085,12 +1156,30 @@ pub(crate) fn to_bytes( modules.len() * size_of::(), ) }; + let modules_ptr = string_builder.append_count(modules_as_bytes); + let compile_exec_argv_ptr = string_builder.append_count_z(compile_exec_argv); + let snapshot_ptr = match snapshot { + Some(img) if !img.is_empty() => { + // The section starts EMBEDDED_SNAPSHOT_ALIGN-aligned in the file with an 8-byte `BlobHeader.size` before payload offset 0, so align (8 + offset). + const BLOB_HEADER_BYTES: usize = size_of::(); + let pad = (EMBEDDED_SNAPSHOT_ALIGN + - ((BLOB_HEADER_BYTES + string_builder.len) % EMBEDDED_SNAPSHOT_ALIGN)) + % EMBEDDED_SNAPSHOT_ALIGN; + if pad != 0 { + let zeros = [0u8; EMBEDDED_SNAPSHOT_ALIGN]; + let _ = string_builder.append(&zeros[..pad]); + } + string_builder.append_count(img) + } + _ => StringPointer::default(), + }; let offsets = Offsets { entry_point_id: entry_point_id.unwrap() as u32, - modules_ptr: string_builder.append_count(modules_as_bytes), - compile_exec_argv_ptr: string_builder.append_count_z(compile_exec_argv), + modules_ptr, + compile_exec_argv_ptr, byte_count: string_builder.len, flags, + snapshot: snapshot_ptr, }; // SAFETY: `Offsets` is `#[repr(C)]` POD; same `sliceAsBytes` rationale as above. @@ -1812,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], @@ -1824,22 +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, - ) { - 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() { @@ -2092,6 +2403,54 @@ pub fn to_executable( } impl StandaloneModuleGraph { + /// The trailer `Offsets` of this executable's own payload, readable long before the graph exists (restore runs first thing in main). + fn offsets_early() -> Option<(*const u8, usize, Offsets)> { + #[cfg(target_os = "macos")] + let data = macho::get_data(); + #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] + let data = elf::get_data(); + #[cfg(windows)] + let data = pe::get_data(); + let (base, len) = data?; + if len < size_of::() + TRAILER.len() { + return None; + } + // SAFETY: bounds checked above; read-only views of the mapped section tail. + let trailer = + unsafe { core::slice::from_raw_parts(base.add(len - TRAILER.len()), TRAILER.len()) }; + if trailer != TRAILER { + return None; + } + // SAFETY: `[len - Offsets - TRAILER, ..)` holds an `Offsets` (possibly unaligned). + let offsets: Offsets = unsafe { + core::ptr::read_unaligned( + base.add(len - size_of::() - TRAILER.len()) + .cast::(), + ) + }; + Some((base.cast_const(), len, offsets)) + } + + pub fn embedded_startup_snapshot_early() -> Option<(*const u8, usize)> { + let (base, len, offsets) = Self::offsets_early()?; + let (off, ilen) = ( + offsets.snapshot.offset as usize, + offsets.snapshot.length as usize, + ); + if ilen == 0 || off.checked_add(ilen).is_none_or(|end| end > len) { + return None; + } + // SAFETY: subrange of the mapped section. + Some((unsafe { base.add(off) }, ilen)) + } + + /// The `TAKE_STARTUP_SNAPSHOT` family of flags stamped by `bun build --snapshot`, or empty. + pub fn startup_snapshot_build_flags_early() -> Flags { + Self::offsets_early().map_or(Flags::empty(), |(_, _, offsets)| { + offsets.flags & Flags::STARTUP_SNAPSHOT_BUILD_BITS + }) + } + /// Loads the standalone module graph from the executable, allocates it on the heap, /// sets it globally, and returns the pointer. pub fn from_executable() -> crate::Result> { diff --git a/src/threading/ThreadPool.rs b/src/threading/ThreadPool.rs index 2b8e5bbe15bc..ef57a46b6b60 100644 --- a/src/threading/ThreadPool.rs +++ b/src/threading/ThreadPool.rs @@ -246,6 +246,32 @@ impl ThreadPool { } } + /// (busy workers, queue non-empty) — the snapshot gate waits for (0, false). + pub fn activity(&self) -> (u16, bool) { + let sync = self.sync.load(Ordering::Relaxed); + ( + sync.spawned().saturating_sub(sync.idle()), + !self.run_queue.is_empty_approx(), + ) + } + + /// snapshot freeze: no other thread may exist (or hold a lock) when memory is frozen. Stop and join every worker; + /// `forget_threads_after_snapshot_restore` resets the state so the pool starts again on the other side. + pub fn stop_all_threads_for_snapshot(&self) { + self.shutdown(); + self.join(); + } + + /// snapshot restore: the worker threads counted in `sync` belonged to the process that built the snapshot. Forget them (queue and + /// config stay) so the next `schedule`/`notify` spawns fresh workers here. + pub fn forget_threads_after_snapshot_restore(&self) { + let sync = Sync::zero(); // pending, nothing spawned/idle/notified — regardless of what the build process left (it may have shut the pool down for the snapshot) + self.sync.0.store(sync.0, Ordering::Release); + self.threads.store(ptr::null_mut(), Ordering::Release); + self.idle_event.reset_after_snapshot_restore(); + self.notify(false); // if anything is queued, this spawns the first worker here + } + /// Dump aggregate worker idle/busy stats to stderr. No-op unless /// `BUN_THREADPOOL_STATS` is set. Safe to call at any time; intended for /// the bundler to call between phases. @@ -1341,6 +1367,13 @@ impl Default for Event { } } +impl Event { + /// Waiter counts in `state` describe threads of the process that built the snapshot. + fn reset_after_snapshot_restore(&self) { + self.state.store(Self::EMPTY, Ordering::Release); + } +} + impl Event { const EMPTY: u32 = 0; const WAITING: u32 = 1; @@ -1518,6 +1551,15 @@ pub mod node { const _ALIGN_CHECK: () = assert!(core::mem::align_of::() >= ((Self::IS_CONSUMING | Self::HAS_CACHE) + 1)); + /// Approximate (racy) emptiness: no pointer bits in `stack` and nothing in the consumer cache. + pub(super) fn is_empty_approx(&self) -> bool { + // Purely from the atomic word: HAS_CACHE mirrors `cache` (only its holder may read the cell itself), and a + // consumer mid-run means work is still in flight, which for "is the pool idle?" is not empty either. + self.stack.load(Ordering::Acquire) + & (Self::PTR_MASK | Self::HAS_CACHE | Self::IS_CONSUMING) + == 0 + } + pub(super) fn push(&self, list: &List) { let List { head, tail } = *list; let mut stack = self.stack.load(Ordering::Relaxed); diff --git a/src/threading/work_pool.rs b/src/threading/work_pool.rs index 60f4a0c98fe6..b4d476cf08a1 100644 --- a/src/threading/work_pool.rs +++ b/src/threading/work_pool.rs @@ -146,6 +146,20 @@ impl WorkPool { POOL.get_or_init(create) } + /// Snapshot: stop and join the workers (if the pool was ever started). + pub fn stop_all_threads_for_snapshot() { + if let Some(pool) = POOL.get() { + pool.stop_all_threads_for_snapshot(); + } + } + + /// Called once right after a snapshot restore, before any task is scheduled. + pub fn did_restore_from_snapshot() { + if let Some(pool) = POOL.get() { + pool.forget_threads_after_snapshot_restore(); + } + } + pub fn schedule(task: *mut Task) { Self::get().schedule(Batch::from(task)); } diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 89a62dcca2b1..7d09fbc948a2 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1782,6 +1782,10 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, { return (struct us_loop_t *)uWS::Loop::get(); } + void uws_adopt_loop_for_current_thread(struct us_loop_t *loop) + { + uWS::Loop::adoptForCurrentThread((uWS::Loop *)loop); + } struct us_loop_t *uws_get_loop_with_native(void *existing_native_loop) { return (struct us_loop_t *)uWS::Loop::get(existing_native_loop); 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/butterfly-fixture.js b/test/js/bun/startup-snapshot/butterfly-fixture.js new file mode 100644 index 000000000000..b9511c2c62c6 --- /dev/null +++ b/test/js/bun/startup-snapshot/butterfly-fixture.js @@ -0,0 +1,16 @@ +// A large array from the snapshot has its storage in an immortal precise allocation; growing it after restore must move it into +// ordinary memory of this process (and leave the snapshot's copy alone), after which the array behaves like any other. +const N = 300_000; +const big = new Array(N); +for (let i = 0; i < N; i++) big[i] = i; +process.on("restore", () => { + for (let i = 0; i < 5000; i++) big.push(N + i); + Bun.gc(true); + for (let i = 0; i < 5000; i++) big.push(N + 5000 + i); + Bun.gc(true); + let ok = big.length === N + 10_000; + for (const i of [0, 1, N - 1, N, N + 4999, N + 5000, N + 9999]) ok &&= big[i] === i; + console.log("[js] grown-after-restore " + (ok ? "ok" : "BROKEN length=" + big.length)); + process.exit(ok ? 0 : 1); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 20); diff --git a/test/js/bun/startup-snapshot/colors-fixture.js b/test/js/bun/startup-snapshot/colors-fixture.js new file mode 100644 index 000000000000..a27f38208ef9 --- /dev/null +++ b/test/js/bun/startup-snapshot/colors-fixture.js @@ -0,0 +1,7 @@ +// Read during the build (piped: false) so the property is reified; the restored launch runs on a terminal and must see its own answer. +void Bun.enableANSIColors; +process.on("restore", () => { + require("fs").writeFileSync(process.env.COLORS_OUT, String(Bun.enableANSIColors)); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/csrf-fixture.js b/test/js/bun/startup-snapshot/csrf-fixture.js new file mode 100644 index 000000000000..084db5b053d6 --- /dev/null +++ b/test/js/bun/startup-snapshot/csrf-fixture.js @@ -0,0 +1,8 @@ +// The default CSRF secret is generated lazily; one generated while building would be the secret of every restored process. +const builtToken = Bun.CSRF.generate(); // forces the default secret into existence before the freeze +process.on("restore", () => { + const fresh = Bun.CSRF.generate(); + console.log(`[js] built-token-verifies=${Bun.CSRF.verify(builtToken)} fresh-token-verifies=${Bun.CSRF.verify(fresh)}`); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/deep-nesting-fixture.js b/test/js/bun/startup-snapshot/deep-nesting-fixture.js new file mode 100644 index 000000000000..0f140409e011 --- /dev/null +++ b/test/js/bun/startup-snapshot/deep-nesting-fixture.js @@ -0,0 +1,16 @@ +// The runtime's recursion guard (its own, apart from JSC's) is per-thread state; a restored process has to have it, or deep +// input through the transpiler is a real stack overflow instead of an error. +function probe() { + const depth = 200_000; + try { + new Bun.Transpiler().transformSync(Buffer.alloc(depth, "[").toString() + Buffer.alloc(depth, "]").toString()); + return "transformed"; + } catch (e) { + return "error: " + String(e.message ?? e).split("\n")[0].slice(0, 60); + } +} +if (process.env.PLAIN) console.log("[js] " + probe()); +else { + process.on("restore", () => { console.log("[js] " + probe()); process.exit(0); }); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} diff --git a/test/js/bun/startup-snapshot/dns-fixture.js b/test/js/bun/startup-snapshot/dns-fixture.js new file mode 100644 index 000000000000..c807694ecd56 --- /dev/null +++ b/test/js/bun/startup-snapshot/dns-fixture.js @@ -0,0 +1,11 @@ +const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); +await fetch(`http://localhost:${server.port}/`).then(r => r.text()); // warms the getaddrinfo cache for "localhost" in the builder +console.log("[js] build", JSON.stringify(Bun.dns.getCacheStats())); +process.on("restore", async () => { + const before = Bun.dns.getCacheStats(); + const s2 = Bun.serve({ port: 0, fetch: () => new Response("ok2") }); + const r = await fetch(`http://localhost:${s2.port}/`); + console.log("[js] restored", JSON.stringify({ before, after: Bun.dns.getCacheStats(), status: r.status, body: await r.text() })); + process.exit(0); +}); +setTimeout(() => { server.stop(true); Bun.startupSnapshot.take({ timers: "cancel" }); }, 100); diff --git a/test/js/bun/startup-snapshot/env-shadowed-fixture.js b/test/js/bun/startup-snapshot/env-shadowed-fixture.js new file mode 100644 index 000000000000..9e0f508ca860 --- /dev/null +++ b/test/js/bun/startup-snapshot/env-shadowed-fixture.js @@ -0,0 +1,5 @@ +process.on("restore", () => { + console.log(`[js] SHADOWED=${process.env.SHADOWED} PLAIN=${process.env.PLAIN} DERIVED=${process.env.DERIVED}`); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); 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/fswatch-fixture.js b/test/js/bun/startup-snapshot/fswatch-fixture.js new file mode 100644 index 000000000000..0183ff5275a3 --- /dev/null +++ b/test/js/bun/startup-snapshot/fswatch-fixture.js @@ -0,0 +1,20 @@ +// The builder owns an FSEvents loop (a watcher exists at snapshot time); a restored process must get a working, fresh one. +const fs = require("fs"); +const path = require("path"); +const dir = process.env.WATCH_DIR; +fs.watch(dir, () => {}); // builder-side watcher: puts a loop (and its CF thread) into the snapshot +process.on("restore", () => { + const dir2 = process.env.WATCH_DIR2; + const seen = []; + const w = fs.watch(dir2, (event, filename) => { seen.push(`${event}:${filename}`); }); + setTimeout(() => fs.writeFileSync(path.join(dir2, "touched.txt"), "x"), 50); + const t0 = Date.now(); + const iv = setInterval(() => { + if (seen.length || Date.now() - t0 > 8000) { + clearInterval(iv); w.close(); + console.log("[js] " + JSON.stringify(seen)); + process.exit(0); + } + }, 20); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 100); diff --git a/test/js/bun/startup-snapshot/gctime-fixture.js b/test/js/bun/startup-snapshot/gctime-fixture.js new file mode 100644 index 000000000000..3d1a0d09afe7 --- /dev/null +++ b/test/js/bun/startup-snapshot/gctime-fixture.js @@ -0,0 +1,10 @@ +const keep = []; for (let i = 0; i < 300000; i++) keep.push({ i, s: "str" + i, a: [i], f() { return i; } }); // ~sizable snapshot heap +function fullGcMs() { const t = performance.now(); Bun.gc(true); return Math.round(performance.now() - t); } +console.log("[js] build: full gc", fullGcMs(), "ms; heap", (process.memoryUsage().heapUsed / 1048576) | 0, "MB"); +process.on("restore", async () => { + console.log("[js] restored: full gc #1", fullGcMs(), "ms"); + const fresh = []; for (let i = 0; i < 200000; i++) fresh.push({ i, k: keep[i % keep.length] }); + console.log("[js] restored: after alloc, full gc #2", fullGcMs(), "ms; #3", fullGcMs(), "ms; heap", (process.memoryUsage().heapUsed / 1048576) | 0, "MB"); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); diff --git a/test/js/bun/startup-snapshot/heavy-fixture.js b/test/js/bun/startup-snapshot/heavy-fixture.js new file mode 100644 index 000000000000..17503e53d6a3 --- /dev/null +++ b/test/js/bun/startup-snapshot/heavy-fixture.js @@ -0,0 +1,17 @@ +// heavier: lots of module-ish closures + Maps, a local HTTP server after restore, fetch to it, fs work on the thread pool +const reg = new Map(); for (let i = 0; i < 50_000; i++) reg.set("k" + i, { i, f: (x) => x + i, arr: [i, i + 1, i + 2] }); +function hot(n) { let s = 0; for (let i = 0; i < n; i++) s += reg.get("k" + (i % 50000)).f(i); return s; } +hot(2_000_000); // tier up before snapshot +async function afterRestore() { + console.log("[js] epoch", Bun.startupSnapshot.epoch(), "hot()", hot(100000)); + const server = Bun.serve({ port: 0, fetch: () => new Response("hello from restored server") }); + const txt = await (await fetch(`http://localhost:${server.port}/`)).text(); + console.log("[js] fetch ->", txt); + await Bun.write(process.env.HEAVY_OUT, "written after restore\n"); + console.log("[js] fs ->", (await Bun.file(process.env.HEAVY_OUT).text()).trim()); + const { stdout } = Bun.spawnSync(["uname", "-m"]); console.log("[js] spawn ->", stdout.toString().trim()); + server.stop(true); process.exit(0); +} +process.on("restore", () => { afterRestore().catch(e => { console.error("[js] FAIL", e); process.exit(1); }); }); +if (Bun.startupSnapshot.isBuildingSnapshot()) setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); +else afterRestore(); diff --git a/test/js/bun/startup-snapshot/highfd-tty-fixture.js b/test/js/bun/startup-snapshot/highfd-tty-fixture.js new file mode 100644 index 000000000000..f9dd109bd94d --- /dev/null +++ b/test/js/bun/startup-snapshot/highfd-tty-fixture.js @@ -0,0 +1,11 @@ +// Bun's own tty reader for stdin is set up on whatever descriptor number is free; the snapshot records that number together +// with the descriptor's flags, and a high number once collided with the flags in the record and came back on the wrong +// descriptor. Use up the low numbers first (files are allowed under local I/O and are simply gone after restore), then set up +// stdin, so its reader lands high; after restore a keystroke has to arrive through it. +const fs = require("fs"); +const held = []; +while (held.length < 40) held.push(fs.openSync(process.execPath, "r")); +process.stdin.setRawMode?.(true); +process.stdin.on("data", d => { console.log(`[js] stdin data after restore: ${JSON.stringify(String(d))}`); process.exit(0); }); +process.on("restore", () => console.log("[js] restored; waiting for a keystroke")); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 100); diff --git a/test/js/bun/startup-snapshot/intl-fixture.js b/test/js/bun/startup-snapshot/intl-fixture.js new file mode 100644 index 000000000000..82d90b5dbaae --- /dev/null +++ b/test/js/bun/startup-snapshot/intl-fixture.js @@ -0,0 +1,42 @@ +// Every Intl object is created before the freeze and used only after restore (or, in a plain run, used right away): each holds +// ICU state, and any of it that were per-process would show up as different output or a crash. Output is compared between the +// two kinds of run by the test. +const objs = { + collator: new Intl.Collator("de"), + dtf: new Intl.DateTimeFormat("en-US", { timeZone: "UTC", dateStyle: "full", timeStyle: "long" }), + nf: new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }), + pr: new Intl.PluralRules("ar-EG"), + rtf: new Intl.RelativeTimeFormat("es", { numeric: "auto" }), + lf: new Intl.ListFormat("en", { type: "conjunction" }), + dn: new Intl.DisplayNames("fr", { type: "region" }), + seg: new Intl.Segmenter("ja", { granularity: "word" }), + locale: new Intl.Locale("en-Latn-US-u-ca-gregory"), + dur: typeof Intl.DurationFormat === "function" ? new Intl.DurationFormat("en", { style: "long" }) : null, +}; +// Iteration objects held across the boundary too: a Segments object, and an iterator that has already been advanced one step. +const heldSegments = objs.seg.segment("今日は良い天気ですね"); +const heldIterator = heldSegments[Symbol.iterator](); +const firstBeforeBoundary = heldIterator.next().value.segment; +function use() { + return [ + ["ä", "a", "z"].sort(objs.collator.compare).join(""), + objs.dtf.format(new Date(Date.UTC(2020, 1, 29, 12, 34, 56))), + objs.nf.format(1234567.891), + [0, 1, 2, 3, 11, 100].map(n => objs.pr.select(n)).join(","), + objs.rtf.format(-1, "day") + "|" + objs.rtf.format(2, "week"), + objs.lf.format(["a", "b", "c"]), + objs.dn.of("JP"), + Array.from(objs.seg.segment("東京都に住んでいます"), s => s.segment).join("/"), + objs.locale.maximize().toString(), + objs.dur ? objs.dur.format({ hours: 1, minutes: 2 }) : "(no DurationFormat)", + heldSegments.containing(3).segment, + firstBeforeBoundary + ">" + Array.from({ length: 3 }, () => heldIterator.next().value?.segment).join("|"), + new Date(0).toLocaleString("en-GB", { timeZone: "UTC" }), + ].join("\n"); +} +if (process.env.PLAIN) { + console.log(use()); +} else { + process.on("restore", () => { console.log(use()); process.exit(0); }); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} 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/ipc-fixture.js b/test/js/bun/startup-snapshot/ipc-fixture.js new file mode 100644 index 000000000000..0be1f67ef0bc --- /dev/null +++ b/test/js/bun/startup-snapshot/ipc-fixture.js @@ -0,0 +1,8 @@ +// Whether this launch has an IPC channel to a parent is decided by the parent that spawned it — the builder had none. +const seenWhileBuilding = typeof process.send; // the usual module-scope `if (process.send)` check: this reifies the property in the builder +process.on("restore", () => { + if (typeof process.send !== "function") { console.log("[js] no process.send after restore"); process.exit(3); } + process.send({ channelVarScrubbed: !("NODE_CHANNEL_FD" in process.env), seenWhileBuilding }); + process.on("message", () => process.exit(0)); // the parent's ack ends the process +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/keeptimers-fixture.js b/test/js/bun/startup-snapshot/keeptimers-fixture.js new file mode 100644 index 000000000000..47d902a0421a --- /dev/null +++ b/test/js/bun/startup-snapshot/keeptimers-fixture.js @@ -0,0 +1,13 @@ +let ticks = 0; +setInterval(() => { ticks++; }, 100); // created BEFORE the snapshot (late-cut style) +process.stdin.setRawMode?.(true); +process.stdin.on("data", d => { console.log(`[js] stdin data: ${JSON.stringify(d.toString())} ticks=${ticks}`); if (d.toString().includes("q")) process.exit(0); }); +let restoredAt = 0; +// Armed with ~1.5 s still to go when the snapshot is taken (~200 ms in): after restore it must fire ~1.5 s later, not at once. +setTimeout(() => console.log(`[js] remaining-time timer fired ${Math.round(performance.now() - restoredAt)}ms after restore`), 1700); +process.on("restore", () => { + restoredAt = performance.now(); + console.log("[js] restored; waiting for ticks + stdin"); + setTimeout(() => console.log(`[js] post-restore timer fired; interval ticks since restore=${ticks}`), 500); // created AFTER restore +}); +setTimeout(() => { ticks = 0; Bun.startupSnapshot.take(process.env.TIMERS ? { timers: process.env.TIMERS } : {}); }, 200); diff --git a/test/js/bun/startup-snapshot/launchctx-fixture.js b/test/js/bun/startup-snapshot/launchctx-fixture.js new file mode 100644 index 000000000000..8ac8e90645f8 --- /dev/null +++ b/test/js/bun/startup-snapshot/launchctx-fixture.js @@ -0,0 +1,11 @@ +// Everything derived from the launching process must reflect the process that RESTORED the snapshot, not the one that built it. +const os = require("os"); +const capturedEnv = process.env; // a reference held across the snapshot (dotenv-style code does this) must see the new environment too +const copiedEnv = { ...process.env }; // a copy cannot; the build reports that it was made +function ctx() { + // pid/execPath are read here during the build on purpose: reading reifies them, and the restored process must still get its own + return { pid: process.pid, execPath: process.execPath, bunCwd: Bun.cwd, colors: Bun.enableANSIColors, argv: process.argv.slice(2), bunArgv: Bun.argv.slice(2), marker: process.env.LAUNCH_MARKER, viaCapturedRef: capturedEnv.LAUNCH_MARKER, viaCopy: copiedEnv.LAUNCH_MARKER, home: os.homedir(), cwd: process.cwd(), execArgv: process.execArgv }; +} +console.log("[js] build " + JSON.stringify(ctx())); +process.on("restore", () => { console.log("[js] restored " + JSON.stringify(ctx())); process.exit(0); }); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); 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/main-throws-fixture.js b/test/js/bun/startup-snapshot/main-throws-fixture.js new file mode 100644 index 000000000000..0ea0e27b7cb2 --- /dev/null +++ b/test/js/bun/startup-snapshot/main-throws-fixture.js @@ -0,0 +1,5 @@ +// main() throwing synchronously has to end the process the way a throw at module scope does: printed, exit code 1. +Bun.startupSnapshot.main(() => { + throw new Error("main threw on purpose"); +}); // an ordinary launch runs it right here and never reaches the next line; the snapshot run keeps it aside +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/main-twice-fixture.js b/test/js/bun/startup-snapshot/main-twice-fixture.js new file mode 100644 index 000000000000..decdc804223a --- /dev/null +++ b/test/js/bun/startup-snapshot/main-twice-fixture.js @@ -0,0 +1,9 @@ +// A program has one main(): the second registration is an error in an ordinary launch and in the snapshot run alike. +Bun.startupSnapshot.main(() => console.log("[js] first main ran")); +try { + Bun.startupSnapshot.main(() => console.log("[js] second main ran")); + console.log("[js] second main accepted"); +} catch (e) { + console.log("[js] second main rejected"); +} +if (!process.env.PLAIN) setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/pdeathsig-fixture.js b/test/js/bun/startup-snapshot/pdeathsig-fixture.js new file mode 100644 index 000000000000..43088221a555 --- /dev/null +++ b/test/js/bun/startup-snapshot/pdeathsig-fixture.js @@ -0,0 +1,14 @@ +// After restore, spawn a plain child and have it report its own PR_GET_PDEATHSIG (Linux); no-orphans mode defaults it to SIGKILL (9). +process.on("restore", async () => { + const env = { ...process.env }; + for (const k of Object.keys(env)) if (k.startsWith("BUN_STARTUP_SNAPSHOT") || k.startsWith("MIMALLOC_")) delete env[k]; + const child = Bun.spawn({ + cmd: [process.execPath, "-e", `const { dlopen, FFIType, ptr } = require("bun:ffi"); const l = dlopen("libc.so.6", { prctl: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.i32 } }); const b = new Int32Array(1); l.symbols.prctl(2, ptr(b)); console.log("pdeathsig=" + b[0]);`], + env, + stdout: "pipe", + }); + console.log("[js] child " + (await child.stdout.text()).trim()); + await child.exited; + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/polls-fixture.js b/test/js/bun/startup-snapshot/polls-fixture.js new file mode 100644 index 000000000000..42b68b827183 --- /dev/null +++ b/test/js/bun/startup-snapshot/polls-fixture.js @@ -0,0 +1,17 @@ +// Built with stdin = a pipe the builder never reads to EOF (a FilePoll on fd 0 is in the snapshot) and after one DNS lookup +// (dns_sd's per-process shared connection). Restored with a fresh stdin pipe: the poll must follow the new fd, and +// nothing may be delivered before 'restore'. +const events = []; +process.stdin.on("data", d => events.push("stdin:" + d.toString().trim())); +process.stdin.on("end", () => events.push("stdin-end")); +process.on("restore", async () => { + events.push("restore"); + const addrs = await Bun.dns.lookup("localhost").catch(e => "ERR:" + e.code); + events.push(Array.isArray(addrs) && addrs.length ? "dns-ok" : "dns:" + JSON.stringify(addrs)); + const deadline = Date.now() + 5000; + while (!events.some(e => e.startsWith("stdin")) && Date.now() < deadline) await Bun.sleep(10); + console.log("[js] " + JSON.stringify(events)); + process.exit(0); +}); +await Bun.dns.lookup("localhost").catch(() => {}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "keep" }), 100); diff --git a/test/js/bun/startup-snapshot/private-memory-fixture.js b/test/js/bun/startup-snapshot/private-memory-fixture.js new file mode 100644 index 000000000000..6dc1c8a19271 --- /dev/null +++ b/test/js/bun/startup-snapshot/private-memory-fixture.js @@ -0,0 +1,17 @@ +// The point of the feature: state built before the freeze lives in the snapshot's shared, clean pages, so a restored process +// has far less private (anonymous) memory than a process that builds the same state itself. Reports RssAnon (Linux) at the +// same program point either way: after the state exists and a full collection has run. +const graph = []; +for (let i = 0; i < 60_000; i++) graph.push({ i, s: "item-" + i, arr: [i, i + 1, i + 2], m: new Map([[i, String(i)]]) }); +globalThis.keep = graph; +function report(label) { + Bun.gc(true); + const kb = Number(/RssAnon:\s+(\d+)/.exec(require("fs").readFileSync("/proc/self/status", "utf8"))[1]); + console.log(`[js] ${label} rss-anon-kb=${kb} items=${keep.length}`); +} +if (process.env.PLAIN) { + report("plain"); +} else { + process.on("restore", () => { report("restored"); process.exit(0); }); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} diff --git a/test/js/bun/startup-snapshot/reified-fixture.js b/test/js/bun/startup-snapshot/reified-fixture.js new file mode 100644 index 000000000000..eb3f46712729 --- /dev/null +++ b/test/js/bun/startup-snapshot/reified-fixture.js @@ -0,0 +1,12 @@ +// Importing from "bun" reifies every lazy property of the Bun object during the build; the launch-derived ones must be this +// launch's afterwards. Captured env references are checked too: the env object keeps its identity and is refilled. +const capturedEnv = Bun.env; +const s3AtBuild = Bun.s3; +const stdoutAtBuild = Bun.stdout; +const redisAtBuild = Bun.redis; // built from REDIS_URL, like s3 from the AWS variables +process.on("restore", () => { + const key = /Credential=([A-Z]+)/.exec(Bun.s3.presign("k", { bucket: "b" }))?.[1]; + console.log(`[js] env=${capturedEnv.MARKER}/${Bun.env.MARKER} sameEnv=${capturedEnv === process.env} s3key=${key} sameS3=${s3AtBuild === Bun.s3} sameStdout=${stdoutAtBuild === Bun.stdout} sameRedis=${redisAtBuild === Bun.redis}`); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/rng-fixture.js b/test/js/bun/startup-snapshot/rng-fixture.js new file mode 100644 index 000000000000..afe5c06d1b68 --- /dev/null +++ b/test/js/bun/startup-snapshot/rng-fixture.js @@ -0,0 +1,7 @@ +const crypto = require("crypto"); +Math.random(); crypto.randomBytes(8); crypto.getRandomValues(new Uint8Array(8)); // touch every RNG before the snapshot +process.on("restore", () => { + console.log("[js]", JSON.stringify({ math: [Math.random(), Math.random()], randomBytes: crypto.randomBytes(8).toString("hex"), webcrypto: Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString("hex"), uuid: crypto.randomUUID(), pid: process.pid, ppid: process.ppid, uptime: process.uptime().toFixed(2), timeOrigin: Math.round(performance.timeOrigin), now: Math.round(performance.now()) })); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); diff --git a/test/js/bun/startup-snapshot/sab-fixture.js b/test/js/bun/startup-snapshot/sab-fixture.js new file mode 100644 index 000000000000..1e442d170f12 --- /dev/null +++ b/test/js/bun/startup-snapshot/sab-fixture.js @@ -0,0 +1,29 @@ +// SharedArrayBuffers created before the freeze: their storage is shared-memory-capable backing that the snapshot carries like +// any other; afterwards they must still hold their contents, still be the same object behind every view, still work with +// Atomics, and a growable one must still grow (growth allocates in the restored process). +const sab = new SharedArrayBuffer(64); +const i32 = new Int32Array(sab); +const u8 = new Uint8Array(sab); +i32[0] = 0x11223344; +i32[1] = 7; +const growable = new SharedArrayBuffer(16, { maxByteLength: 1024 }); +new Uint8Array(growable)[3] = 42; +function check() { + const out = []; + out.push("i32[0]=" + i32[0].toString(16)); + out.push("aliased=" + (u8[0] === 0x44 || u8[3] === 0x44)); // same storage seen through both views (either endianness) + out.push("sameBuffer=" + (i32.buffer === sab && u8.buffer === sab)); + out.push("atomicsAdd=" + Atomics.add(i32, 1, 5) + "->" + Atomics.load(i32, 1)); + out.push("notify=" + Atomics.notify(i32, 2, 1)); // 0 waiters, but the call must work + growable.grow(512); + const g = new Uint8Array(growable); + g[500] = 9; + out.push("grown=" + growable.byteLength + " kept=" + g[3] + " new=" + g[500]); + return out.join(" "); +} +if (process.env.PLAIN) { + console.log("[js] " + check()); +} else { + process.on("restore", () => { console.log("[js] " + check()); process.exit(0); }); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} 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/smoke-fixture.js b/test/js/bun/startup-snapshot/smoke-fixture.js new file mode 100644 index 000000000000..4ebf5dc8f792 --- /dev/null +++ b/test/js/bun/startup-snapshot/smoke-fixture.js @@ -0,0 +1,6 @@ +const big = Array.from({ length: 200_000 }, (_, i) => ({ i, s: "x" + i })); +let n = 0; +function startTicking() { setInterval(() => { n++; console.log("[js] tick", n, "len", big.length, "big[123].s", big[123].s); if (n >= 3) process.exit(0); }, 200); } +process.on("restore", () => { console.log("[js] restored! epoch", Bun.startupSnapshot.epoch()); startTicking(); }); +if (Bun.startupSnapshot.isBuildingSnapshot()) setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); +else startTicking(); diff --git a/test/js/bun/startup-snapshot/spawnsync-fixture.js b/test/js/bun/startup-snapshot/spawnsync-fixture.js new file mode 100644 index 000000000000..f23e2e8b6a80 --- /dev/null +++ b/test/js/bun/startup-snapshot/spawnsync-fixture.js @@ -0,0 +1,16 @@ +const { spawnSync } = require("child_process"); +const run = (tag) => { + const opts = [ + ["default", {}], + ["stdio-ignore-pipe-pipe", { stdio: ["ignore", "pipe", "pipe"] }], + ["shell+ignore", { shell: "/bin/sh", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1_000_000, timeout: 600000 }], + ["shell+pipe-in", { shell: "/bin/sh", stdio: ["pipe", "pipe", "pipe"], input: "" }], + ]; + for (const [name, o] of opts) { + const r = o.shell ? spawnSync("echo out; echo err 1>&2", o) : spawnSync("/bin/sh", ["-c", "echo out; echo err 1>&2"], o); + console.log(`[js] ${tag} ${name}: status=${r.status} stdout=${JSON.stringify(String(r.stdout ?? ""))} stderr=${JSON.stringify(String(r.stderr ?? ""))} err=${r.error?.code ?? "-"}`); + } +}; +if (!process.env.SKIP_BUILD_RUN) { try { run("build"); } catch (e) { console.log("[js] build run threw", String(e.message).slice(0, 50)); } } +process.on("restore", () => { run("restored"); process.exit(0); }); +setTimeout(() => Bun.startupSnapshot.take({ timers: "keep" }), 50); 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/startup-snapshot-harness.ts b/test/js/bun/startup-snapshot/startup-snapshot-harness.ts new file mode 100644 index 000000000000..eea1fcac3661 --- /dev/null +++ b/test/js/bun/startup-snapshot/startup-snapshot-harness.ts @@ -0,0 +1,30 @@ +import { test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isMacOS, tempDir } from "harness"; +import { join } from "path"; + +// Snapshot round-trip: the fixture snapshots itself at idle, a fresh process restores it and continues. +export const env = { ...bunEnv, MIMALLOC_DETERMINISTIC_HINT: "1", BUN_STARTUP_SNAPSHOT_JIT_ADDR: "0x3c0000000" }; +export const buildEnv = env; +export const restoreEnv = { ...env, MIMALLOC_HINT_FLOOR: "0x21000000000", BUN_STARTUP_SNAPSHOT_VERBOSE: "1" }; // a restoring process keeps its own early heap above where snapshot regions get mapped +// Support is a property of the build under test (platform, ASAN, and on macOS whether mimalloc is the process allocator); a +// build that lacks it says so as soon as it is asked to take one. +export const hasSnapshots = (() => { + if (!isLinux && !isMacOS) return false; + using dir = tempDir("bun-snapshot-probe", {}); + const probe = Bun.spawnSync({ + cmd: [bunExe(), "-e", ""], + env: { ...bunEnv, BUN_STARTUP_SNAPSHOT_OUT: join(String(dir), "probe.snapshot") }, + stderr: "pipe", + stdout: "pipe", + }); + return !probe.stderr.toString().includes("not available in this build"); +})(); + +// Every test here is a build + restore round-trip of a fixture (about a second in a release build, an order of magnitude more +// under ASAN; two of them additionally drive a terminal), so they share one generous ceiling instead of each picking its own. +const ROUND_TRIP_TIMEOUT_MS = 60_000; +export function withSnapshots(alsoRequires = true) { + const t = test.skipIf(!hasSnapshots || !alsoRequires); + return (name: string, fn: () => void | Promise) => t(name, fn, ROUND_TRIP_TIMEOUT_MS); +} +export const snapshotTest = withSnapshots(); diff --git a/test/js/bun/startup-snapshot/startup-snapshot.test.ts b/test/js/bun/startup-snapshot/startup-snapshot.test.ts new file mode 100644 index 000000000000..9cd06aaa55dc --- /dev/null +++ b/test/js/bun/startup-snapshot/startup-snapshot.test.ts @@ -0,0 +1,1175 @@ +import { expect } from "bun:test"; +import { copyFileSync, existsSync, linkSync, realpathSync } from "fs"; +import { bunExe, isLinux, tempDir, tls } from "harness"; +import { join } from "path"; +import { buildEnv, restoreEnv, snapshotTest, withSnapshots } from "./startup-snapshot-harness"; + +for (const fixture of ["smoke-fixture.js", "heavy-fixture.js"]) { + snapshotTest(`snapshot round-trip: ${fixture}`, async () => { + using dir = tempDir("bun-snapshot", {}); + const img = join(String(dir), "app.snapshot"); + const build = Bun.spawnSync({ + cmd: [bunExe(), join(import.meta.dir, fixture)], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stderr: "pipe", + stdout: "pipe", + }); + expect(build.stderr.toString()).toContain("[snapshot] wrote"); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, fixture)], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, HEAVY_OUT: join(String(dir), "heavy.out") }, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("[snapshot] restored"); + expect(stdout).toContain("epoch 1"); + if (fixture === "heavy-fixture.js") { + expect(stdout).toContain("fetch -> hello from restored server"); + expect(stdout).toContain("fs -> written after restore"); + } else expect(stdout).toContain("[js] tick 3"); + expect(exitCode).toBe(0); + }); +} + +snapshotTest("stdin's tty reader set up on a high descriptor number still delivers input after restore", async () => { + using dir = tempDir("bun-snapshot-highfd", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "highfd-tty-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_STARTUP_SNAPSHOT_IO: "local" }, + terminal: { cols: 80, rows: 24, data() {} }, + }); + expect(await p.exited).toBe(0); + } + let out = ""; + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + terminal: { + cols: 80, + rows: 24, + data(_t, d) { + out += new TextDecoder().decode(d); + }, + }, + }); + const deadline = Date.now() + 20_000; + while (!out.includes("waiting for a keystroke") && Date.now() < deadline) await Bun.sleep(20); + expect(out).toContain("waiting for a keystroke"); + expect(out).toMatch(/\[snapshot\] dup2\(\d, [4-9]\d\)/); // the record really carried a high descriptor number (engagement, not just outcome) + p.terminal!.write("z"); + const exit = await Promise.race([p.exited, Bun.sleep(10_000).then(() => "no exit within 10s" as const)]); + expect(exit, out).toBe(0); + expect(out).toContain('stdin data after restore: "z"'); +}); + +snapshotTest("the default CSRF secret generated while building is not the secret of a restored process", async () => { + using dir = tempDir("bun-snapshot-csrf", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "csrf-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("built-token-verifies=false fresh-token-verifies=true"); // used to be true: the builder's secret, everywhere + expect(code).toBe(0); +}); + +snapshotTest("a worker that was terminated no longer blocks a snapshot, and the restored process runs", async () => { + using dir = tempDir("bun-snapshot-worker-done", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "worker-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, TERMINATE_FIRST: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).not.toContain("worker thread(s) still running"); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, TERMINATE_FIRST: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("restored after a terminated worker"); + expect(code).toBe(0); +}); + +const waiterEnv = { + ...buildEnv, + BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1", + BUN_GARBAGE_COLLECTOR_LEVEL: "0", + BUN_STARTUP_SNAPSHOT_IO: "local", +}; // the flag is read alongside the GC level; spawning is local I/O +withSnapshots(isLinux)( + "the subprocess waiter thread is stopped for the freeze and a restored process starts its own", + async () => { + using dir = tempDir("bun-snapshot-waiter", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "waiter-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...waiterEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_STARTUP_SNAPSHOT_VERBOSE: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] stopped the subprocess waiter thread"); // it was really running, and really stopped + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { + ...restoreEnv, + BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1", + BUN_GARBAGE_COLLECTOR_LEVEL: "0", + BUN_STARTUP_SNAPSHOT_IN: img, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("spawn after restore exited 0"); + expect(code).toBe(0); + }, +); + +withSnapshots(!isLinux)( + "where the subprocess waiter thread cannot be stopped, a snapshot is refused while it runs, by name", + async () => { + using dir = tempDir("bun-snapshot-waiter-refused", {}); + await using p = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "waiter-fixture.js")], + env: { + ...waiterEnv, + BUN_STARTUP_SNAPSHOT_OUT: join(String(dir), "s.snapshot"), + BUN_STARTUP_SNAPSHOT_QUIET_TIMEOUT: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("subprocess waiter thread is running"); + expect(existsSync(join(String(dir), "s.snapshot"))).toBe(false); + expect(code).toBe(70); + }, +); + +snapshotTest( + "a restored process spawned with an ipc channel can use it, and hides the channel variable from its env", + async () => { + using dir = tempDir("bun-snapshot-ipc", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "ipc-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + const { promise: received, resolve } = Promise.withResolvers(); + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + ipc(message, child) { + resolve(message); + child.send("ack"); + }, + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).not.toContain("no process.send"); // what a restored process used to say + expect(await received).toEqual({ channelVarScrubbed: true, seenWhileBuilding: "undefined" }); // reified as undefined while building, a function now + expect(code).toBe(0); + }, +); + +snapshotTest("main() registered twice is rejected the same way whether or not a snapshot is being taken", async () => { + using dir = tempDir("bun-snapshot-main-twice", {}); + const fixture = join(import.meta.dir, "main-twice-fixture.js"); + const plain = Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + expect(plain.stdout.toString()).toBe("[js] first main ran\n[js] second main rejected\n"); // used to run both + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: join(String(dir), "s.snapshot") }, + stdout: "pipe", + stderr: "pipe", + }); + const [out] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toBe("[js] second main rejected\n"); // kept aside, and the second one rejected rather than replacing it +}); + +snapshotTest( + "main() throwing in a restored process exits 1 with the error printed, exactly like a normal boot", + async () => { + using dir = tempDir("bun-snapshot-main-throws", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "main-throws-fixture.js"); + const plain = Bun.spawnSync({ cmd: [bunExe(), fixture], env: buildEnv, stderr: "pipe", stdout: "pipe" }); + expect(plain.stderr.toString()).toContain("main threw on purpose"); + expect(plain.exitCode).toBe(1); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); // main() was kept aside and the take() after it ran + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("main threw on purpose"); + expect(code).toBe(plain.exitCode); // 1; a restored process used to carry on and exit 0 + }, +); + +snapshotTest("an unhandled rejection in a restored process exits 1, exactly like a normal boot", async () => { + using dir = tempDir("bun-snapshot-unhandled", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "unhandled-fixture.js"); + const plain = Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stderr: "pipe", + stdout: "pipe", + }); + expect(plain.exitCode).toBe(1); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("unhandled after restore"); + expect(code).toBe(plain.exitCode); // 1; a restored process used to exit 0 here +}); + +snapshotTest( + "the runtime's recursion guard is armed in a restored process: deep input is an error, not a crash", + async () => { + using dir = tempDir("bun-snapshot-deep", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "deep-nesting-fixture.js"); + const plain = Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const plainOut = plain.stdout.toString(); + expect(plainOut).toContain("[js] error:"); // the guard turns it into an error in a normal boot + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toBe(plainOut); // used to die of a real stack overflow here: the guard's per-thread state was never set up + expect(code).toBe(0); + }, +); + +snapshotTest( + "dates in a restored process follow the launch's time zone, whether TZ is set differently or not at all", + async () => { + using dir = tempDir("bun-snapshot-tz", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "tz-fixture.js"); + { + // Built under a zone no launch below uses, so each launch shape is distinguishable from "kept the builder's"; an + // unparseable TZ resolves to UTC, which is why the build is not simply left at CI's UTC. + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, TZ: "Asia/Tokyo", BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + const { TZ: _utc, ...withoutTZ } = restoreEnv; + for (const launchEnv of [ + { ...restoreEnv, TZ: "Europe/Berlin" }, + { ...restoreEnv, TZ: "America/Argentina/ComodRivadavia" }, // 32 characters: the restore path once capped names below that while boot did not + withoutTZ, + { ...restoreEnv, TZ: "Not/AZone" }, + ]) { + // the last one ICU rejects: system zone, as at boot + const plain = Bun.spawnSync({ + cmd: [bunExe(), fixture], + env: { ...launchEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...launchEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toBe(plain.stdout.toString()); // used to report the builder's zone; the rejected one still did after the first fix + } + }, +); + +snapshotTest( + "Bun.s3 and the stdio blobs reified during the build are this launch's after restore; the env object is refilled in place", + async () => { + using dir = tempDir("bun-snapshot-reified", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "reified-fixture.js"); + const aws = { AWS_SECRET_ACCESS_KEY: "unused", AWS_REGION: "us-east-1" }; + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, ...aws, MARKER: "build", AWS_ACCESS_KEY_ID: "BUILDKEY", BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, ...aws, MARKER: "launch", AWS_ACCESS_KEY_ID: "LAUNCHKEY", BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("env=launch/launch sameEnv=true"); // a captured reference sees the launch's variables + expect(out).toContain("s3key=LAUNCHKEY sameS3=false"); // used to sign with BUILDKEY: the reified client was the builder's + expect(out).toContain("sameStdout=false sameRedis=false"); // both remade for the launch + expect(code).toBe(0); + { + // A launch whose REDIS_URL cannot even be parsed: the property is reported and left undefined, and 'restore' still fires. + await using bad = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { + ...restoreEnv, + ...aws, + MARKER: "launch", + AWS_ACCESS_KEY_ID: "LAUNCHKEY", + REDIS_URL: "::not a url::", + BUN_STARTUP_SNAPSHOT_IN: img, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [badOut, badErr, badCode] = await Promise.all([bad.stdout.text(), bad.stderr.text(), bad.exited]); + expect(badErr).toContain("[snapshot] Bun.redis could not be remade for this launch"); + expect(badOut).toContain("s3key=LAUNCHKEY"); // the restore listener ran; before, the pending exception kept it from firing + expect(badCode).toBe(0); + } + }, +); + +snapshotTest( + "a thread can be started in a restored process (every mapping the builder owned comes back, resident or not)", + async () => { + // Falsified by an allocator whose page-map tables can be entirely untouched at build time (upstream mimalloc dev3 as of Aug 2026): + // the writer used to drop such mappings, and the first thread's startup then dereferenced one. + using dir = tempDir("bun-snapshot-thread-after", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "thread-after-restore-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("thread started after restore"); + expect(code).toBe(0); + }, +); + +snapshotTest("SharedArrayBuffers from before the freeze keep working after restore, growth included", async () => { + using dir = tempDir("bun-snapshot-sab", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "sab-fixture.js"); + const plainRun = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [plainOut] = await Promise.all([plainRun.stdout.text(), plainRun.stderr.text(), plainRun.exited]); + expect(plainOut).toContain("aliased=true sameBuffer=true atomicsAdd=7->12"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(out).toBe(plainOut); + expect(code).toBe(0); +}); + +snapshotTest("Intl objects created before the freeze work after restore and agree with a plain run", async () => { + using dir = tempDir("bun-snapshot-intl", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "intl-fixture.js"); + const plainRun = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [plainOut] = await Promise.all([plainRun.stdout.text(), plainRun.stderr.text(), plainRun.exited]); + expect(plainOut.split("\n").length).toBeGreaterThanOrEqual(13); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [restoredOut, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(restoredOut).toBe(plainOut); + expect(code).toBe(0); +}); + +snapshotTest("the frozen heap holds up under JSC's GC verifier", async () => { + // JSC options are frozen at VM init, so they travel with the snapshot: set at build time, the verifier re-marks every collection + // in the restored process independently of the immortal fast paths and RELEASE_ASSERTs on any disagreement. Engagement was + // confirmed by timing (a verified full collection here takes ~40 ms against ~3 ms without); the assertion is that it stays quiet. + using dir = tempDir("bun-snapshot-verifygc", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "gctime-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_JSC_verifyGC: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(out).toContain("; #3 "); // the third verified full collection completed + expect(code).toBe(0); +}); + +const memoryTest = withSnapshots(isLinux); // RssAnon is a clean private-memory figure; macOS exposes nothing equivalent cheaply, so there the round-trips are checked functionally only +memoryTest("a restored process holds much less private memory than one that builds the same state itself", async () => { + using dir = tempDir("bun-snapshot-private-memory", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "private-memory-fixture.js"); + const anon = (out: string) => Number(/rss-anon-kb=(\d+)/.exec(out)![1]); + const plainRun = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, PLAIN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [plainOut] = await Promise.all([plainRun.stdout.text(), plainRun.stderr.text(), plainRun.exited]); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [restoredOut, err] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(restoredOut).toContain("items=60000"); + const plain = anon(plainOut), + restored = anon(restoredOut); + // The graph is tens of MB private in the plain run and lives in clean snapshot pages in the restored one; the ratio has a + // wide margin (measured ~2x on the real programs in the PR description), so this fails only if the feature stops working. + expect(restored, `plain ${plain} KB vs restored ${restored} KB`).toBeLessThan(plain * 0.75); +}); + +snapshotTest( + "process.execPath is where this launch's executable is, even when the snapshot was built at another path", + async () => { + // The same executable at a second path (a hard link, so it is byte-identical and the snapshot accepts it): build with one, restore with the other. + using dir = tempDir("bun-snapshot-execpath", {}); + const other = join(String(dir), "bun-elsewhere"); + try { + linkSync(bunExe(), other); + } catch (e: any) { + if (e?.code !== "EXDEV") throw e; + copyFileSync(bunExe(), other); // tmp on another filesystem: a copy is just as byte-identical + } + const img = join(String(dir), "s.snapshot"); + const code = `void process.execPath; process.on("restore", () => { console.log("[js] execPath=" + process.execPath); process.exit(0); }); setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10);`; + await Bun.write(join(String(dir), "app.js"), code); + { + await using p = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "app.js")], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , c] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(c).toBe(0); + } + await using p = Bun.spawn({ + cmd: [other, join(String(dir), "app.js")], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(out).toContain("[js] execPath=" + realpathSync(other)); + }, +); + +snapshotTest("a snapshot is refused while a worker thread is running, and says so", async () => { + using dir = tempDir("bun-snapshot-worker", {}); + await using p = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "worker-fixture.js")], + env: { + ...buildEnv, + BUN_STARTUP_SNAPSHOT_OUT: join(String(dir), "s.snapshot"), + BUN_STARTUP_SNAPSHOT_QUIET_TIMEOUT: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("worker thread(s) still running"); + expect(existsSync(join(String(dir), "s.snapshot"))).toBe(false); + expect(code).toBe(70); // the runtime's "did not become quiet" exit +}); + +snapshotTest("a strict build refuses servers and UDP sockets, not just listen/connect", async () => { + using dir = tempDir("bun-snapshot-strict-servers", {}); + await using p = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "strict-servers-fixture.js")], + env: { + ...buildEnv, + BUN_STARTUP_SNAPSHOT_OUT: join(String(dir), "s.snapshot"), + CP_TARGET: join(String(dir), "copy"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [out] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("[js] serve refused"); + expect(out).toContain("[js] udp refused"); + for (const op of [ + "readdir", + "cp", + "watch", + "readdir-async", + "cp-async", + "bun-write", + "bun-file-text", + "bun-file-exists", + "bun-file-stat", + "bun-file-delete", + "s3-blob-text", + "s3-blob-stat", + "s3-blob-delete", + "s3-client-stat", + "s3-client-write", + "s3-client-list", + "dns-resolve-mx", + "dns-lookup-service", + ]) + expect(out).toContain(`[js] ${op} refused`); // hand-written node:fs bindings + for (const op of ["stdout-write", "stdin-access"]) expect(out).toContain(`[js] ${op} created`); // stdio is exempt from the gate +}); + +snapshotTest("Bun.enableANSIColors reified during a piped build is re-derived for a launch on a terminal", async () => { + using dir = tempDir("bun-snapshot-colors", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "colors-fixture.js"); + const outFile = join(String(dir), "colors.txt"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, NO_COLOR: undefined, BUN_STARTUP_SNAPSHOT_IN: img, COLORS_OUT: outFile }, // bunEnv sets NO_COLOR; this launch is the one that may color + terminal: { cols: 80, rows: 24, data() {} }, + }); + expect(await p.exited).toBe(0); + expect(await Bun.file(outFile).text()).toBe("true"); // the builder's "false" is what a stale property would carry +}); + +snapshotTest("an array from the snapshot can grow past its capacity after restore", async () => { + using dir = tempDir("bun-snapshot-butterfly", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "butterfly-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(out).toContain("[js] grown-after-restore ok"); + expect(code).toBe(0); +}); + +snapshotTest("TLS verification derived from the builder's environment is re-derived at restore", async () => { + using dir = tempDir("bun-snapshot-tls-reject", { "cert.pem": tls.cert, "key.pem": tls.key }); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "tls-reject-fixture.js"); + const files = { TLS_CERT: join(String(dir), "cert.pem"), TLS_KEY: join(String(dir), "key.pem") }; + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { + ...buildEnv, + ...files, + BUN_STARTUP_SNAPSHOT_OUT: img, + BUN_STARTUP_SNAPSHOT_IO: "network", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain("[js] build ok 200"); + expect(code).toBe(0); + } + const { NODE_TLS_REJECT_UNAUTHORIZED: _unset, ...launchEnv } = { + ...restoreEnv, + ...files, + BUN_STARTUP_SNAPSHOT_IN: img, + }; + await using p = Bun.spawn({ cmd: [bunExe(), fixture], env: launchEnv, stdout: "pipe", stderr: "pipe" }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toContain("[snapshot] restored"); + expect(out).toContain("[js] restored rejected"); // the builder's "don't verify" must not be what this launch runs with + expect(code).toBe(0); +}); + +snapshotTest( + "a .env value the builder's environ shadowed reaches a launch whose environ lacks it, as a normal boot would", + async () => { + using dir = tempDir("bun-snapshot-env-shadowed", { + ".env": "SHADOWED=from-dotenv\nPLAIN=plain-dotenv\nDERIVED=${PLAIN}/v1\n", + }); + using launchDir = tempDir("bun-snapshot-env-shadowed-launch", {}); // no .env here: whatever a launch sees came through the snapshot + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "env-shadowed-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + cwd: String(dir), + env: { + ...buildEnv, + BUN_STARTUP_SNAPSHOT_OUT: img, + SHADOWED: "from-builder-environ", + DERIVED: "from-builder-environ", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + const launch = async (extra: Record) => { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + cwd: String(launchDir), + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, ...extra }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + return out.trim(); + }; + expect(await launch({})).toBe("[js] SHADOWED=from-dotenv PLAIN=plain-dotenv DERIVED=plain-dotenv/v1"); + expect(await launch({ SHADOWED: "from-launch" })).toBe( + "[js] SHADOWED=from-launch PLAIN=plain-dotenv DERIVED=plain-dotenv/v1", + ); // the launch's environ still wins + }, +); + +withSnapshots(isLinux)("children of a restored no-orphans process still get the parent-death signal", async () => { + // The builder armed no-orphans mode on its main thread; the restored main thread has to count as that thread too. + using dir = tempDir("bun-snapshot-pdeathsig", {}); + const img = join(String(dir), "s.snapshot"); + const fixture = join(import.meta.dir, "pdeathsig-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_FEATURE_FLAG_NO_ORPHANS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + expect(existsSync(img)).toBe(true); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, BUN_FEATURE_FLAG_NO_ORPHANS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out.trim()).toBe("[js] child pdeathsig=9"); + expect(code).toBe(0); +}); + +snapshotTest("a snapshot built with the parent-death watchdog on does not fire it at restore", async () => { + // The builder's watch (on the builder's parent) is in the snapshot; a restored process must watch its own parent instead of + // acting on the inherited one. Before the fix every restored launch exited 129 on macOS. + using dir = tempDir("bun-snapshot-no-orphans", {}); + const img = join(String(dir), "s.snapshot"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "smoke-fixture.js")], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_FEATURE_FLAG_NO_ORPHANS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "smoke-fixture.js")], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, BUN_FEATURE_FLAG_NO_ORPHANS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).toContain("[snapshot] restored"); + expect(stdout).toContain("epoch 1"); + expect(code).toBe(0); +}); + +snapshotTest("launch context (argv, env, cwd, HOME) comes from the restoring process, not the builder", async () => { + using dir = tempDir("bun-snapshot-launchctx", { + a: { ".keep": "" }, + b: { ".keep": "" }, + homeA: { ".keep": "" }, + homeB: { ".keep": "" }, + }); + const img = join(String(dir), "ctx.snapshot"); + const fixture = join(import.meta.dir, "launchctx-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture, "built-arg"], + // BUN_OPTIONS is spliced into argv; the build has one token and the launch below has none, so argv must be re-derived, not inherited. + env: { + ...buildEnv, + BUN_STARTUP_SNAPSHOT_OUT: img, + LAUNCH_MARKER: "builder", + HOME: join(String(dir), "homeA"), + BUN_OPTIONS: "--silent", + }, + cwd: join(String(dir), "a"), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain('"marker":"builder"'); + // The build names what was read from process.env before the freeze (and that it was copied wholesale once). + expect(err).toContain("values read from process.env before the freeze are baked into the snapshot"); + expect(err).toContain("process.env was enumerated or copied 1 time"); + expect(err).toMatch(/1 copy from:\n\s+at .*launchctx-fixture\.js/); // attributed to the fixture's spread + expect(err).not.toMatch(/\n (?!process\.env was )\S/); // a copy covers every name: no per-name list + expect(code).toBe(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture, "restored-arg", "--flag"], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, LAUNCH_MARKER: "restorer", HOME: join(String(dir), "homeB") }, + cwd: join(String(dir), "b"), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const line = out.split("\n").find(l => l.startsWith("[js] restored ")); + expect(line, err.slice(-2000)).toBeDefined(); + const got = JSON.parse(line!.slice("[js] restored ".length)); + expect(got.pid).toBe(p.pid); // not the builder's, even though the builder read it + expect(got.execPath).toBe(bunExe()); + expect(got.bunCwd.endsWith("/b")).toBe(true); // Bun.cwd was reified in "a" during the build + // enableANSIColors is refreshed by the same loop as Bun.cwd above; telling it apart would need a pty-backed launch (both runs here are piped). + expect(got.argv).toEqual(["restored-arg", "--flag"]); + expect(got.bunArgv).toEqual(["restored-arg", "--flag"]); + expect(got.marker).toBe("restorer"); + expect(got.viaCapturedRef).toBe("restorer"); + expect(got.viaCopy).toBe("builder"); + expect(got.home).toBe(join(String(dir), "homeB")); + expect(got.cwd.endsWith("/b")).toBe(true); + expect(code).toBe(0); +}); + +snapshotTest("full GC right after restore is not stalled by the builder's parked threads", async () => { + using dir = tempDir("bun-snapshot-gctime", {}); + const img = join(String(dir), "gct.snapshot"); + const fixture = join(import.meta.dir, "gctime-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); // drained together: a chatty build must not block on a full pipe + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const m = out.match(/full gc #2 (\d+) ms; #3 (\d+) ms/); + expect(m, err.slice(-1000)).not.toBeNull(); + // was 10_000 ms (AutomaticThread timeout) before ParkingLot entries were dropped at restore + expect(Number(m![1])).toBeLessThan(2000); + expect(Number(m![2])).toBeLessThan(2000); + expect(code).toBe(0); +}); + +snapshotTest( + 'timers: "keep" — timers armed before the snapshot keep running after restore, re-based on the new clock; stdin still delivers', + async () => { + using dir = tempDir("bun-snapshot-keeptimers", {}); + const img = join(String(dir), "kt.snapshot"); + const fixture = join(import.meta.dir, "keeptimers-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, TIMERS: "keep" }, + terminal: { cols: 80, rows: 24, data() {} }, + }); + await p.exited; + } + let out = ""; + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + terminal: { + cols: 80, + rows: 24, + data(_t, d) { + out += new TextDecoder().decode(d); + }, + }, + }); + const deadline = Date.now() + 20000; + while (!/remaining-time timer fired (\d+)ms after restore/.test(out) && Date.now() < deadline) await Bun.sleep(50); + const ticks = Number(/interval ticks since restore=(\d+)/.exec(out)?.[1] ?? -1); + expect(ticks).toBeGreaterThanOrEqual(2); // 100 ms interval over ~500 ms; 0 would mean the pre-snapshot interval died + expect(ticks).toBeLessThan(50); // not a burst of catch-up fires from un-rebased deadlines + // The one-shot had ~1.5 s left at the freeze; it must still have ~1.5 s left after restore (an un-rebased deadline + // would be long past and fire immediately). Upper bound is loose for slow (debug/ASAN) runners. + // The timer had 1700 ms minus however long the (possibly slow, loaded) builder took to reach take() left at the freeze; + // an un-rebased deadline would be long past and fire within a few ms of restore, which is what the lower bound rejects. + const remaining = Number(/remaining-time timer fired (\d+)ms after restore/.exec(out)![1]); + expect(remaining).toBeGreaterThanOrEqual(400); + expect(remaining).toBeLessThan(5000); + p.terminal!.write("q"); + // On failure the output says which half broke: no "stdin data" line means the keystroke never reached the kept stream; + // the line without an exit means process.exit() from its handler did not complete. + const exit = await Promise.race([p.exited, Bun.sleep(10_000).then(() => "no exit within 10s" as const)]); + expect(exit, out).toBe(0); + expect(out).toContain('stdin data: "q"'); + }, +); + +snapshotTest( + "spawnSync used before the snapshot still works after restore (isolated spawnSync loop is rebuilt)", + async () => { + using dir = tempDir("bun-snapshot-spawnsync", {}); + const img = join(String(dir), "ss.snapshot"); + const fixture = join(import.meta.dir, "spawnsync-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_STARTUP_SNAPSHOT_IO: "local" }, + stdout: "pipe", + stderr: "pipe", + }); + const [out] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(out).toContain('[js] build default: status=0 stdout="out\\n"'); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(err).toMatch(/\[snapshot\] rebased [1-9]\d* timers/); // the kept interval was moved onto this process's clock (on one machine the old deadlines are merely overdue, which the tick counts cannot tell apart) + for (const variant of ["default", "stdio-ignore-pipe-pipe", "shell+ignore", "shell+pipe-in"]) { + expect(out, err.slice(-600)).toContain(`[js] restored ${variant}: status=0 stdout="out\\n" stderr="err\\n"`); + } + expect(code).toBe(0); + }, +); + +snapshotTest("random sources and time bases are fresh in every process restored from the same snapshot", async () => { + using dir = tempDir("bun-snapshot-rng", {}); + const img = join(String(dir), "rng.snapshot"); + const fixture = join(import.meta.dir, "rng-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img }, + stdout: "pipe", + stderr: "pipe", + }); + await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); // drained together: a chatty build must not block on a full pipe + } + const runs: any[] = []; + for (let i = 0; i < 2; i++) { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const line = out.split("\n").find(l => l.startsWith("[js] ")); + expect(line, err.slice(-600)).toBeDefined(); + runs.push(JSON.parse(line!.slice(5))); + expect(code).toBe(0); + } + const [a, b] = runs; + expect(a.math).not.toEqual(b.math); // Math.random (JSGlobalObject WeakRandom) + expect(a.webcrypto).not.toBe(b.webcrypto); // crypto.getRandomValues (entropy cache) + expect(a.uuid).not.toBe(b.uuid); // crypto.randomUUID + expect(a.randomBytes).not.toBe(b.randomBytes); // BoringSSL RAND_bytes + expect(Number(a.uptime)).toBeLessThan(5); // counts from this launch, not the builder's + expect(a.now).toBeLessThan(5000); + expect(b.timeOrigin).toBeGreaterThanOrEqual(a.timeOrigin); +}); + +snapshotTest("DNS answers cached by the builder are not served after restore; keep-alive pool recovers", async () => { + using dir = tempDir("bun-snapshot-dns", {}); + const img = join(String(dir), "dns.snapshot"); + const fixture = join(import.meta.dir, "dns-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_STARTUP_SNAPSHOT_IO: "network" }, + stdout: "pipe", + stderr: "pipe", + }); + const [out] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(JSON.parse(out.match(/\[js\] build (.*)/)![1]).size).toBeGreaterThan(0); + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, BUN_CONFIG_VERBOSE_FETCH: "curl" }, // the builder's fetch latched "not verbose"; this launch asks for it + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const m = out.match(/\[js\] restored (.*)/); + expect(m, err.slice(-600)).not.toBeNull(); + const r = JSON.parse(m![1]); + expect(r.before.size).toBe(0); // flushed at restore + expect(r.after.cacheHitsCompleted).toBe(0); // the post-restore lookup was a miss, i.e. asked this machine + expect(r.status).toBe(200); + expect(r.body).toBe("ok2"); + expect(err).toContain("curl "); // verbose fetch honored after restore, i.e. re-derived from this launch's environment + expect(code).toBe(0); +}); + +snapshotTest( + "restore: 'restore' precedes any poll delivery; a stdio poll follows the re-seated fd; dns works again", + async () => { + using dir = tempDir("bun-snapshot-polls", {}); + const img = join(String(dir), "polls.snapshot"); + const fixture = join(import.meta.dir, "polls-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...buildEnv, BUN_STARTUP_SNAPSHOT_OUT: img, BUN_STARTUP_SNAPSHOT_IO: "local" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + await p.exited; // stdin pipe deliberately left open and unread-to-EOF + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + p.stdin.write("hello\n"); + await p.stdin.flush(); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const m = out.match(/\[js\] (.*)/); + expect(m, err.slice(-800)).not.toBeNull(); + const events = JSON.parse(m![1]) as string[]; + expect(events[0]).toBe("restore"); + expect(events).toContain("dns-ok"); + expect(events).toContain("stdin:hello"); // the builder's fd-0 poll was re-armed on this process's stdin + expect(code).toBe(0); + }, +); + +snapshotTest("fs.watch works in a restored process even though the builder had a watcher thread", async () => { + using dir = tempDir("bun-snapshot-fswatch", { a: { ".keep": "" }, b: { ".keep": "" } }); + const img = join(String(dir), "w.snapshot"); + const fixture = join(import.meta.dir, "fswatch-fixture.js"); + { + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { + ...buildEnv, + BUN_STARTUP_SNAPSHOT_OUT: img, + BUN_STARTUP_SNAPSHOT_IO: "local", + WATCH_DIR: join(String(dir), "a"), + }, + stdout: "pipe", + stderr: "pipe", + }); + await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); // drained together: a chatty build must not block on a full pipe + } + await using p = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { ...restoreEnv, BUN_STARTUP_SNAPSHOT_IN: img, WATCH_DIR2: join(String(dir), "b") }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + const m = out.match(/\[js\] (.*)/); + expect(m, err.slice(-600)).not.toBeNull(); + expect(JSON.parse(m![1]).some((e: string) => e.endsWith(":touched.txt"))).toBe(true); + expect(code).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/strict-servers-fixture.js b/test/js/bun/startup-snapshot/strict-servers-fixture.js new file mode 100644 index 000000000000..311c6c888167 --- /dev/null +++ b/test/js/bun/startup-snapshot/strict-servers-fixture.js @@ -0,0 +1,41 @@ +// A strict build must refuse anything that would freeze an OS socket into the snapshot: servers and UDP sockets included. +async function attempt(name, make) { + try { + let s = make(); + if (s && typeof s.then === "function") s = await s; // the Bun.file/Bun.write forms reject rather than throw + s?.stop?.(true); s?.close?.(); + console.log(`[js] ${name} created`); + } catch (e) { + console.log(String(e?.message ?? e).includes("while building a snapshot") ? `[js] ${name} refused` : `[js] ${name} failed otherwise: ${String(e?.message ?? e).slice(0, 40)}`); + } +} +const attempts = []; +const queue = (name, make) => attempts.push([name, make]); +queue("serve", () => Bun.serve({ port: 0, fetch: () => new Response("x") })); +queue("udp", () => Bun.udpSocket({ port: 0 })); +// node:fs ops implemented outside the generated table must be gated like the rest. +const fs = require("fs"); +queue("readdir", () => fs.readdirSync(".")); +queue("cp", () => fs.cpSync(process.execPath, process.env.CP_TARGET, {})); +queue("watch", () => fs.watch(".")); +queue("readdir-async", () => new Promise((res, rej) => fs.readdir(".", e => (e ? rej(e) : res())))); // the callback forms are the hand-written bindings +queue("cp-async", () => new Promise((res, rej) => fs.cp(process.execPath, process.env.CP_TARGET, {}, e => (e ? rej(e) : res())))); // refused through the callback, as node delivers errors +// Bun's own file APIs are gated too, not just node:fs. +queue("bun-write", () => Bun.write(process.env.CP_TARGET, "x")); +queue("bun-file-text", () => Bun.file(process.execPath).text()); +queue("bun-file-exists", () => Bun.file(process.execPath).exists()); +queue("bun-file-stat", () => Bun.file(process.execPath).stat()); +queue("bun-file-delete", () => Bun.file(process.env.CP_TARGET).delete()); // a path that does not exist: ungated, this fails with ENOENT rather than "refused" +queue("s3-blob-stat", () => Bun.s3.file("k", { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" }).stat()); +queue("s3-blob-delete", () => Bun.s3.file("k", { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" }).delete()); +queue("s3-client-stat", () => Bun.s3.stat("k", { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" })); // the client-level entries are a separate family from the blob methods +queue("s3-client-write", () => Bun.s3.write("k", "x", { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" })); +queue("s3-client-list", () => Bun.s3.list({}, { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" })); +const dns = require("node:dns"); +queue("dns-resolve-mx", () => dns.promises.resolveMx("snapshot.invalid")); // the per-record-type methods share one helper; resolve() itself was gated separately +queue("dns-lookup-service", () => dns.promises.lookupService("127.0.0.1", 22)); +queue("s3-blob-text", () => Bun.s3.file("k", { bucket: "b", endpoint: "http://127.0.0.1:9", accessKeyId: "a", secretAccessKey: "b" }).text()); // an S3-backed blob is network I/O +// ...but stdio is each launch's own and must stay usable while building (process.stdin is built on the same machinery). +queue("stdout-write", () => Bun.write(Bun.stdout, "")); +queue("stdin-access", () => { if (!process.stdin) throw new Error("no stdin"); }); +(async () => { for (const [n, m] of attempts) await attempt(n, m); Bun.startupSnapshot.take({ timers: "cancel" }); })(); diff --git a/test/js/bun/startup-snapshot/thread-after-restore-fixture.js b/test/js/bun/startup-snapshot/thread-after-restore-fixture.js new file mode 100644 index 000000000000..f5aed546cae2 --- /dev/null +++ b/test/js/bun/startup-snapshot/thread-after-restore-fixture.js @@ -0,0 +1,7 @@ +// The first new thread in a restored process makes the allocator set up per-thread state, which walks tables the builder may +// never have touched; every mapping the builder owned has to exist after restore, resident or not. +process.on("restore", () => { + const w = new Worker(URL.createObjectURL(new Blob(["postMessage(1)"], { type: "application/javascript" }))); + w.onmessage = () => { console.log("[js] thread started after restore"); process.exit(0); }; +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); diff --git a/test/js/bun/startup-snapshot/tls-reject-fixture.js b/test/js/bun/startup-snapshot/tls-reject-fixture.js new file mode 100644 index 000000000000..c7d1c18adbbe --- /dev/null +++ b/test/js/bun/startup-snapshot/tls-reject-fixture.js @@ -0,0 +1,21 @@ +// Whether TLS certificates are verified is derived lazily from the environment; a value derived in the builder must not +// survive into a launch whose environment says otherwise. The build runs with verification off, the launch with it on. +const tls = { cert: Bun.file(process.env.TLS_CERT), key: Bun.file(process.env.TLS_KEY) }; +async function probe(label) { + const server = Bun.serve({ port: 0, tls, fetch: () => new Response("ok") }); + try { + const r = await fetch(`https://localhost:${server.port}/`); + console.log(`[js] ${label} ok ${r.status}`); + } catch (e) { + console.log(`[js] ${label} rejected ${e.code ?? e.name}`); + } finally { + server.stop(true); + } +} +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // written from JS as well as inherited: both places it can be latched from +await probe("build"); +process.on("restore", async () => { + await probe("restored"); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 50); diff --git a/test/js/bun/startup-snapshot/tz-fixture.js b/test/js/bun/startup-snapshot/tz-fixture.js new file mode 100644 index 000000000000..cd9da186dab2 --- /dev/null +++ b/test/js/bun/startup-snapshot/tz-fixture.js @@ -0,0 +1,8 @@ +// Dates follow the launch's zone (TZ, or the machine's), not the zone of the process that built the snapshot. +const report = () => `[js] offset=${new Date(0).getTimezoneOffset()} zone=${Intl.DateTimeFormat().resolvedOptions().timeZone} str=${new Date(0).toString().slice(16, 33)}`; +new Date().toString(); // populate the caches while building, as a real app would +if (process.env.PLAIN) console.log(report()); +else { + process.on("restore", () => { console.log(report()); process.exit(0); }); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} diff --git a/test/js/bun/startup-snapshot/unhandled-fixture.js b/test/js/bun/startup-snapshot/unhandled-fixture.js new file mode 100644 index 000000000000..667663ee306d --- /dev/null +++ b/test/js/bun/startup-snapshot/unhandled-fixture.js @@ -0,0 +1,7 @@ +// An unhandled rejection after restore has to end the process the same way it does in a normally-booted one: exit code 1. +function fail() { Promise.reject(new Error("unhandled after restore")); } +if (process.env.PLAIN) fail(); +else { + process.on("restore", fail); + setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); +} diff --git a/test/js/bun/startup-snapshot/waiter-fixture.js b/test/js/bun/startup-snapshot/waiter-fixture.js new file mode 100644 index 000000000000..3f97b254ea84 --- /dev/null +++ b/test/js/bun/startup-snapshot/waiter-fixture.js @@ -0,0 +1,10 @@ +// Spawning on a build that uses the waiter thread (forced here) starts that thread; a snapshot cannot contain it. The child is +// sh rather than bun: a child bun would inherit the snapshot variables and restore (or take) as well. +const child = () => Bun.spawn({ cmd: ["/bin/sh", "-c", "exit 0"], stdout: "ignore", stderr: "ignore" }).exited; +await child(); +process.on("restore", async () => { + const code = await child(); // reaped by a waiter thread started in this process + console.log(`[js] spawn after restore exited ${code}`); + process.exit(0); +}); +setTimeout(() => Bun.startupSnapshot.take({ timers: "cancel" }), 10); 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()}`); +}); diff --git a/test/js/bun/startup-snapshot/worker-fixture.js b/test/js/bun/startup-snapshot/worker-fixture.js new file mode 100644 index 000000000000..822e27a9871b --- /dev/null +++ b/test/js/bun/startup-snapshot/worker-fixture.js @@ -0,0 +1,8 @@ +// A thread cannot be in a snapshot: taking one while a worker is running has to be refused, and refused by name. +const w = new Worker(URL.createObjectURL(new Blob(["setInterval(() => {}, 1000);"], { type: "application/javascript" }))); +w.addEventListener("open", async () => { + if (process.env.TERMINATE_FIRST) await w.terminate(); // then the count must be back to zero — after the thread has fully torn down + Bun.startupSnapshot.take({ timers: "cancel" }); +}); +if (process.env.TERMINATE_FIRST) process.on("restore", () => { console.log("[js] restored after a terminated worker"); process.exit(0); }); +setTimeout(() => process.exit(3), 20_000); // safety net only; the runtime gives up first (the test shortens its wait) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index ddac1fd1afcb..e1d92fb4199b 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -576,7 +576,7 @@ it("process.versions", () => { const expectedVersions = { boringssl: "1a41b9025c2c0a37edd07ff10f6944f03e028522", libarchive: "ded82291ab41d5e355831b96b0e1ff49e24d8939", - mimalloc: "1803341d6241d8fa4b3f65fa68cb13a32ad92f04", + mimalloc: "7aca49e5b5b49ce2e44490a604d93a4be7a39759", picohttpparser: "066d2b1e9ab820703db0837a7255d92d30f0c9f5", zlib: "12731092979c6d07f42da27da673a9f6c7b13586", tinycc: "05f0fafaa3be31e31d7b4b5c17dc60f62c991171",