mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
Compare commits
1 Commits
deps/updat
...
cursor/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b37c7daa4 |
187
hmr-research.md
Normal file
187
hmr-research.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Bun Hot Module Reloading (HMR) and Hot Code Swapping Research
|
||||
|
||||
## Overview
|
||||
|
||||
Bun implements hot module reloading (HMR) and hot code swapping through a sophisticated system that combines file watching, incremental bundling, WebSocket communication, and a runtime HMR module system. The implementation supports both client-side and server-side hot reloading, with special support for React Fast Refresh.
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. File Watching System
|
||||
|
||||
Bun uses platform-specific file watchers on a separate thread:
|
||||
|
||||
- **Linux**: INotifyWatcher using inotify system calls
|
||||
- **macOS**: KEventWatcher using kqueue
|
||||
- **Windows**: WindowsWatcher using IOCP and ReadDirectoryChangesW
|
||||
|
||||
The watcher system:
|
||||
|
||||
- Runs on its own thread to avoid blocking the main event loop
|
||||
- Coalesces multiple file change events to reduce noise
|
||||
- Communicates with the main thread using atomic operations and concurrent tasks
|
||||
- Supports watching both files and directories recursively
|
||||
|
||||
### 2. DevServer and Incremental Bundler
|
||||
|
||||
The DevServer (`src/bake/DevServer.zig`) is the core component that:
|
||||
|
||||
- Manages the incremental bundling process
|
||||
- Maintains separate dependency graphs for client and server code
|
||||
- Tracks which files need rebundling when changes occur
|
||||
- Handles WebSocket connections for pushing updates to clients
|
||||
|
||||
Key features:
|
||||
|
||||
- Incremental bundling: Only rebuilds changed modules and their dependents
|
||||
- Dual graphs: Separate incremental graphs for client and server code
|
||||
- Source map management with reference counting
|
||||
- Asset serving and caching
|
||||
|
||||
### 3. WebSocket Protocol
|
||||
|
||||
The WebSocket protocol (`src/bake/DevServer.zig:MessageId`) uses binary messages with a single-byte message ID prefix:
|
||||
|
||||
- **Version (V)**: Sent on connection to ensure client/server compatibility
|
||||
- **Hot Update (u)**: Contains updated modules, CSS changes, and route updates
|
||||
- **Errors (e)**: Sends bundling/runtime errors to display in overlay
|
||||
- **Memory Visualizer (M)**: Debug information about memory usage
|
||||
|
||||
The protocol is designed for efficiency:
|
||||
|
||||
- Binary transport for minimal overhead
|
||||
- Batched updates to reduce round trips
|
||||
- Client-side deduplication of updates
|
||||
|
||||
### 4. HMR Runtime
|
||||
|
||||
The HMR runtime consists of two main parts:
|
||||
|
||||
#### Client-side Runtime (`src/bake/hmr-module.ts`)
|
||||
|
||||
- Implements the `import.meta.hot` API compatible with Vite
|
||||
- Manages module registry and dependency tracking
|
||||
- Handles module replacement and state preservation
|
||||
- Supports React Fast Refresh for component hot reloading
|
||||
|
||||
#### Server-side Runtime (`src/bake/hmr-runtime-server.ts`)
|
||||
|
||||
- Handles server-side module replacement
|
||||
- Manages route updates without full page reloads
|
||||
- Integrates with framework-specific reloading mechanisms
|
||||
|
||||
### 5. Module System Features
|
||||
|
||||
#### Module Registry
|
||||
|
||||
- Each module gets a unique ID and is stored in a registry
|
||||
- Modules can be ESM or CommonJS
|
||||
- Module state is preserved across reloads using `import.meta.hot.data`
|
||||
|
||||
#### HMR Boundaries
|
||||
|
||||
- Modules can self-accept updates with `import.meta.hot.accept()`
|
||||
- Parent modules can accept updates for dependencies
|
||||
- If no boundary is found, triggers a full page reload
|
||||
- Automatic boundary detection for modules using `import.meta.hot.data`
|
||||
|
||||
#### React Fast Refresh
|
||||
|
||||
- Automatic integration when React is detected
|
||||
- Wraps React components with refresh signatures
|
||||
- Preserves component state during updates
|
||||
- Uses a hash-based system to detect hook changes
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Change Detection Flow
|
||||
|
||||
1. **File System Event**: Platform watcher detects file change
|
||||
2. **Event Coalescing**: Multiple rapid changes are batched (100μs window)
|
||||
3. **Hot Reload Event**: Created and passed to DevServer thread
|
||||
4. **Dependency Analysis**: Determines which modules are affected
|
||||
5. **Incremental Bundle**: Only rebuilds changed modules
|
||||
6. **WebSocket Push**: Sends updates to connected clients
|
||||
7. **Runtime Update**: Client applies changes without page reload
|
||||
|
||||
### Incremental Bundling Strategy
|
||||
|
||||
The incremental bundler maintains:
|
||||
|
||||
- **File indices**: Maps file paths to graph nodes
|
||||
- **Import/export tracking**: Knows module dependencies
|
||||
- **Stale file tracking**: Marks files needing rebuild
|
||||
- **Edge tracking**: Import relationships between modules
|
||||
|
||||
When a file changes:
|
||||
|
||||
1. Mark the file and its importers as stale
|
||||
2. Trace through the dependency graph
|
||||
3. Find HMR boundaries (self-accepting modules)
|
||||
4. Bundle only the stale modules
|
||||
5. Generate minimal update payloads
|
||||
|
||||
### CSS Hot Reloading
|
||||
|
||||
CSS updates are handled specially:
|
||||
|
||||
- CSS files are tracked separately in the bundler
|
||||
- Changes trigger immediate style updates without JS evaluation
|
||||
- Uses a content-based hash system for deduplication
|
||||
- Supports both `<link>` and `<style>` tag updates
|
||||
|
||||
### Error Handling and Recovery
|
||||
|
||||
The system includes sophisticated error handling:
|
||||
|
||||
- Bundling errors are captured and sent to clients
|
||||
- Error overlay displays compilation errors
|
||||
- Graceful fallback to full reload on critical errors
|
||||
- Atomic updates ensure consistency
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **Memory Recycling**: Reuses HotReloadEvent objects between updates
|
||||
2. **Atomic Operations**: Lock-free communication between threads
|
||||
3. **Reference Counting**: Source maps are reference counted for memory efficiency
|
||||
4. **Lazy Evaluation**: Modules are only loaded when imported
|
||||
5. **Binary Protocol**: Minimal overhead in WebSocket communication
|
||||
6. **Incremental Bundling**: Only rebuilds what changed
|
||||
|
||||
## React Fast Refresh Integration
|
||||
|
||||
Bun automatically detects React and enables Fast Refresh:
|
||||
|
||||
- Identifies React components by naming convention
|
||||
- Wraps components with refresh registration calls
|
||||
- Hashes hook usage to detect incompatible changes
|
||||
- Falls back to full reload for hook signature changes
|
||||
|
||||
## Framework Integration
|
||||
|
||||
The HMR system is designed to work with various frameworks:
|
||||
|
||||
- Provides `onServerSideReload` hook for framework integration
|
||||
- Supports server component boundaries
|
||||
- Handles route-based code splitting
|
||||
- Preserves framework-specific state
|
||||
|
||||
## Testing and Debugging
|
||||
|
||||
The implementation includes extensive testing support:
|
||||
|
||||
- Synchronization primitives for deterministic tests
|
||||
- WebSocket message inspection and batching
|
||||
- Memory visualization for debugging leaks
|
||||
- Incremental bundler visualization
|
||||
|
||||
## Limitations and Trade-offs
|
||||
|
||||
1. **Browser Compatibility**: Some features require modern browsers
|
||||
2. **Memory Usage**: Keeping module state increases memory footprint
|
||||
3. **Complex Dependencies**: Circular dependencies can cause issues
|
||||
4. **State Preservation**: Not all state can be preserved automatically
|
||||
5. **Framework Constraints**: Some frameworks may not fully support HMR
|
||||
|
||||
## Conclusion
|
||||
|
||||
Bun's HMR implementation is a sophisticated system that combines efficient file watching, incremental bundling, and a flexible runtime to provide fast development feedback. The architecture prioritizes performance through careful use of threads, atomic operations, and incremental updates while maintaining compatibility with existing HMR APIs like Vite's `import.meta.hot`.
|
||||
@@ -138,6 +138,7 @@ using JSNonFinalObject = JSC::JSNonFinalObject;
|
||||
namespace JSCastingHelpers = JSC::JSCastingHelpers;
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(Process_functionCwd);
|
||||
JSC_DECLARE_CUSTOM_GETTER(processKChannelHandle);
|
||||
|
||||
extern "C" uint8_t Bun__getExitCode(void*);
|
||||
extern "C" uint8_t Bun__setExitCode(void*, uint8_t);
|
||||
@@ -3732,6 +3733,35 @@ void Process::finishCreation(JSC::VM& vm)
|
||||
|
||||
putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("process"_s)), 0);
|
||||
putDirect(vm, Identifier::fromString(vm, "_exiting"_s), jsBoolean(false), 0);
|
||||
|
||||
// Add support for process[kChannelHandle] to access the channel
|
||||
// This is needed for Node.js internal/child_process compatibility
|
||||
auto* globalObject = this->globalObject();
|
||||
if (Bun__GlobalObject__hasIPC(globalObject)) {
|
||||
auto kChannelHandle = Identifier::fromUid(vm.symbolRegistry().symbolForKey("nodejs.internal.child_process.channel"_s));
|
||||
putDirectCustomAccessor(vm, kChannelHandle,
|
||||
CustomGetterSetter::create(vm, processKChannelHandle, nullptr),
|
||||
PropertyAttribute::CustomAccessor | PropertyAttribute::DontEnum);
|
||||
}
|
||||
}
|
||||
|
||||
JSC_DEFINE_CUSTOM_GETTER(processKChannelHandle, (JSC::JSGlobalObject* lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
|
||||
{
|
||||
auto& vm = lexicalGlobalObject->vm();
|
||||
|
||||
Process* process = jsCast<Process*>(JSValue::decode(thisValue));
|
||||
if (!process) {
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
// First check if the channel property is already initialized
|
||||
JSValue channelValue = process->getDirect(vm, Identifier::fromString(vm, "channel"_s));
|
||||
if (!channelValue.isEmpty()) {
|
||||
return JSValue::encode(channelValue);
|
||||
}
|
||||
|
||||
// If not initialized, construct it
|
||||
return JSValue::encode(constructProcessChannel(vm, process));
|
||||
}
|
||||
|
||||
} // namespace Bun
|
||||
|
||||
@@ -40,7 +40,7 @@ static StringView extractCookieName(const StringView& cookie)
|
||||
{
|
||||
auto nameEnd = cookie.find('=');
|
||||
if (nameEnd == notFound)
|
||||
return String();
|
||||
return StringView();
|
||||
return cookie.substring(0, nameEnd);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const JSC = bun.JSC;
|
||||
const Output = bun.Output;
|
||||
const ZigString = JSC.ZigString;
|
||||
const log = Output.scoped(.IPC, false);
|
||||
const IPC = @import("../ipc.zig");
|
||||
|
||||
extern fn Bun__Process__queueNextTick1(*JSC.JSGlobalObject, JSC.JSValue, JSC.JSValue) void;
|
||||
extern fn Process__emitErrorEvent(global: *JSC.JSGlobalObject, value: JSC.JSValue) void;
|
||||
@@ -299,3 +300,40 @@ export fn Bun__shouldIgnoreOneDisconnectEventListener(globalObject: *JSC.JSGloba
|
||||
const vm = globalObject.bunVM();
|
||||
return vm.channel_ref_should_ignore_one_disconnect_event_listener;
|
||||
}
|
||||
|
||||
pub fn channelReadStop(globalObject: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue {
|
||||
const vm = globalObject.bunVM();
|
||||
if (vm.getIPCInstance()) |instance| {
|
||||
if (Environment.isWindows) {
|
||||
if (instance.data.getSocket()) |socket| {
|
||||
socket.asStream().readStop();
|
||||
}
|
||||
} else {
|
||||
// On POSIX, we need to pause reading by unregistering the poll
|
||||
// This is a no-op for now as POSIX IPC is event-driven
|
||||
// and doesn't have an explicit pause mechanism
|
||||
}
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
|
||||
pub fn channelReadStart(globalObject: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue {
|
||||
const vm = globalObject.bunVM();
|
||||
if (vm.getIPCInstance()) |instance| {
|
||||
if (Environment.isWindows) {
|
||||
if (instance.data.getSocket()) |socket| {
|
||||
_ = socket.asStream().readStart(
|
||||
&instance.data,
|
||||
IPC.IPCHandlers.WindowsNamedPipe.onReadAlloc,
|
||||
IPC.IPCHandlers.WindowsNamedPipe.onReadError,
|
||||
IPC.IPCHandlers.WindowsNamedPipe.onRead,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// On POSIX, we need to resume reading by re-registering the poll
|
||||
// This is a no-op for now as POSIX IPC is event-driven
|
||||
// and doesn't have an explicit pause mechanism
|
||||
}
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,9 @@ export function windowsEnv(
|
||||
export function getChannel() {
|
||||
const EventEmitter = require("node:events");
|
||||
const setRef = $newZigFunction("node_cluster_binding.zig", "setRef", 1);
|
||||
const readStop = $newZigFunction("node_cluster_binding.zig", "channelReadStop", 0);
|
||||
const readStart = $newZigFunction("node_cluster_binding.zig", "channelReadStart", 0);
|
||||
|
||||
return new (class Control extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -449,5 +452,13 @@ export function getChannel() {
|
||||
unref() {
|
||||
setRef(false);
|
||||
}
|
||||
|
||||
readStop() {
|
||||
readStop();
|
||||
}
|
||||
|
||||
readStart() {
|
||||
readStart();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
4
src/js/internal/child_process.ts
Normal file
4
src/js/internal/child_process.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This module provides Node.js-compatible internal APIs for child_process IPC
|
||||
const kChannelHandle = Symbol.for("nodejs.internal.child_process.channel");
|
||||
|
||||
export { kChannelHandle };
|
||||
35
test-channel.js
Normal file
35
test-channel.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { kChannelHandle } = require("internal/child_process");
|
||||
|
||||
// Test that we can access process[kChannelHandle] when running with IPC
|
||||
if (process.argv[2] === "child") {
|
||||
// In child process
|
||||
if (process.channel) {
|
||||
console.log("Has channel:", !!process.channel);
|
||||
console.log("Has kChannelHandle:", !!process[kChannelHandle]);
|
||||
console.log("Are they the same:", process.channel === process[kChannelHandle]);
|
||||
|
||||
if (process[kChannelHandle]) {
|
||||
console.log("Has readStop:", typeof process[kChannelHandle].readStop);
|
||||
console.log("Has readStart:", typeof process[kChannelHandle].readStart);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
// In parent process
|
||||
const { spawn } = require("child_process");
|
||||
const child = spawn(process.execPath, [__filename, "child"], {
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", data => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
child.stderr.on("data", data => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
child.on("exit", code => {
|
||||
console.log("Child exited with code", code);
|
||||
});
|
||||
}
|
||||
42
test-filtered.js
Normal file
42
test-filtered.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
// Run the test with the debug build
|
||||
const testPath = path.join(__dirname, "test/js/node/test/parallel/test-child-process-recv-handle.js");
|
||||
const child = spawn("./build/debug/bun-debug", [testPath], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, BUN_DEBUG_QUIET_LOGS: "1" },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", data => {
|
||||
// Filter out debug output lines that start with [
|
||||
const lines = data.toString().split("\n");
|
||||
const filtered = lines.filter(line => !line.startsWith("[")).join("\n");
|
||||
if (filtered) {
|
||||
process.stdout.write(filtered);
|
||||
}
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", data => {
|
||||
stderr += data.toString();
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on("close", code => {
|
||||
if (code !== 0) {
|
||||
console.error("\nTest failed with exit code:", code);
|
||||
if (stdout.includes("AssertionError")) {
|
||||
// Extract and display the assertion error
|
||||
const errorStart = stdout.indexOf("AssertionError");
|
||||
const errorSection = stdout.substring(errorStart);
|
||||
console.error("\nAssertion Error Details:");
|
||||
console.error(errorSection.split("\n").slice(0, 10).join("\n"));
|
||||
}
|
||||
} else {
|
||||
console.log("\nTest passed!");
|
||||
}
|
||||
});
|
||||
33
test-ipc-channel-handle.js
Normal file
33
test-ipc-channel-handle.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
if (process.argv[2] === "child") {
|
||||
// Child process
|
||||
const { kChannelHandle } = require("internal/child_process");
|
||||
|
||||
console.log("Child: kChannelHandle is", typeof kChannelHandle);
|
||||
console.log("Child: process.channel is", typeof process.channel);
|
||||
console.log("Child: process[kChannelHandle] is", typeof process[kChannelHandle]);
|
||||
|
||||
if (process[kChannelHandle]) {
|
||||
console.log("Child: readStop is", typeof process[kChannelHandle].readStop);
|
||||
console.log("Child: readStart is", typeof process[kChannelHandle].readStart);
|
||||
|
||||
// Test calling these methods
|
||||
process[kChannelHandle].readStop();
|
||||
console.log("Child: Called readStop()");
|
||||
|
||||
process[kChannelHandle].readStart();
|
||||
console.log("Child: Called readStart()");
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Parent process
|
||||
const child = spawn(process.execPath, [__filename, "child"], {
|
||||
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
|
||||
child.on("exit", code => {
|
||||
console.log(`Parent: Child exited with code ${code}`);
|
||||
});
|
||||
}
|
||||
86
test/js/node/test/parallel/test-child-process-recv-handle.js
Normal file
86
test/js/node/test/parallel/test-child-process-recv-handle.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright Joyent, Inc. and other Node contributors.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the
|
||||
// "Software"), to deal in the Software without restriction, including
|
||||
// without limitation the rights to use, copy, modify, merge, publish,
|
||||
// distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
// persons to whom the Software is furnished to do so, subject to the
|
||||
// following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included
|
||||
// in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
// USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
'use strict';
|
||||
// Test that a Linux specific quirk in the handle passing protocol is handled
|
||||
// correctly. See https://github.com/joyent/node/issues/5330 for details.
|
||||
|
||||
const common = require('../common');
|
||||
const assert = require('assert');
|
||||
const net = require('net');
|
||||
const spawn = require('child_process').spawn;
|
||||
|
||||
if (process.argv[2] === 'worker')
|
||||
worker();
|
||||
else
|
||||
primary();
|
||||
|
||||
function primary() {
|
||||
// spawn() can only create one IPC channel so we use stdin/stdout as an
|
||||
// ad-hoc command channel.
|
||||
const proc = spawn(process.execPath, [
|
||||
'--expose-internals', __filename, 'worker',
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
|
||||
});
|
||||
let handle = null;
|
||||
proc.on('exit', () => {
|
||||
handle.close();
|
||||
});
|
||||
proc.stdout.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ok\r\n');
|
||||
net.createServer(common.mustNotCall()).listen(0, function() {
|
||||
handle = this._handle;
|
||||
proc.send('one');
|
||||
proc.send('two', handle);
|
||||
proc.send('three');
|
||||
proc.stdin.write('ok\r\n');
|
||||
});
|
||||
}));
|
||||
proc.stderr.pipe(process.stderr);
|
||||
}
|
||||
|
||||
function worker() {
|
||||
const { kChannelHandle } = require('internal/child_process');
|
||||
process[kChannelHandle].readStop(); // Make messages batch up.
|
||||
process.stdout.ref();
|
||||
process.stdout.write('ok\r\n');
|
||||
process.stdin.once('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ok\r\n');
|
||||
process[kChannelHandle].readStart();
|
||||
}));
|
||||
let n = 0;
|
||||
process.on('message', common.mustCall((msg, handle) => {
|
||||
n += 1;
|
||||
if (n === 1) {
|
||||
assert.strictEqual(msg, 'one');
|
||||
assert.strictEqual(handle, undefined);
|
||||
} else if (n === 2) {
|
||||
assert.strictEqual(msg, 'two');
|
||||
assert.ok(handle !== null && typeof handle === 'object');
|
||||
handle.close();
|
||||
} else if (n === 3) {
|
||||
assert.strictEqual(msg, 'three');
|
||||
assert.strictEqual(handle, undefined);
|
||||
process.exit();
|
||||
}
|
||||
}, 3));
|
||||
}
|
||||
Reference in New Issue
Block a user