Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
054e5b2e72 [autofix.ci] apply automated fixes 2025-10-13 19:41:25 +00:00
Claude Bot
e7c624e773 Add missing module polling in watch mode
When a script tries to import a file that doesn't exist in watch mode,
Bun now polls for the missing module instead of exiting with an error.
This allows developers to create the file and have Bun automatically
reload once it appears.

## Implementation

- Created `MissingModulePollTimer.zig` that polls for missing files
  using exponential backoff (2ms to 100ms intervals)
- Added timer integration to EventLoopTimer enum and fire handler
- Integrated polling detection in `VirtualMachine.uncaughtException()`
  and `handlePendingInternalPromiseRejection()`
- Detects MODULE_NOT_FOUND and ERR_MODULE_NOT_FOUND error codes
- Triggers hot reload when the missing file appears

## Testing

Added tests that verify the module polling works correctly when
a missing module is created after the process starts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:38:21 +00:00
7 changed files with 417 additions and 1 deletions

View File

@@ -644,6 +644,51 @@ pub fn handledPromise(this: *jsc.VirtualMachine, globalObject: *JSGlobalObject,
return Bun__emitHandledPromiseEvent(globalObject, promise);
}
/// Check if an error is a module-not-found error and start polling for it in watch mode
fn tryHandleMissingModuleInWatchMode(this: *jsc.VirtualMachine, globalObject: *JSGlobalObject, err: JSValue) void {
// Only poll in watch mode
if (this.hot_reload != .watch) return;
if (!err.isObject()) return;
const error_obj = err.uncheckedPtrCast(jsc.JSObject);
// Get the error code without triggering getters
const code_value = error_obj.getCodePropertyVMInquiry(globalObject) orelse return;
if (!code_value.isString()) return;
const code_str = code_value.toBunString(globalObject) catch return;
defer code_str.deref();
// Check if it's a module-not-found error code
const is_module_not_found = code_str.eqlComptime("ERR_MODULE_NOT_FOUND") or
code_str.eqlComptime("MODULE_NOT_FOUND");
if (!is_module_not_found) return;
// Try to extract the specifier
const specifier_value = (err.get(globalObject, "specifier") catch return) orelse return;
if (!specifier_value.isString()) return;
const specifier = specifier_value.toBunString(globalObject) catch return;
defer specifier.deref();
// Convert to a slice
const specifier_slice = specifier.toUTF8(bun.default_allocator);
defer specifier_slice.deinit();
// Start polling for this path
this.timer.missing_module_poll_timer.startPolling(this, specifier_slice.slice()) catch {
// If we can't start polling, just continue normally
return;
};
Output.prettyErrorln("<r><yellow>Waiting for module to exist:<r> {s}", .{specifier_slice.slice()});
Output.flush();
}
pub fn uncaughtException(this: *jsc.VirtualMachine, globalObject: *JSGlobalObject, err: JSValue, is_rejection: bool) bool {
if (this.isShuttingDown()) {
return true;
@@ -665,6 +710,10 @@ pub fn uncaughtException(this: *jsc.VirtualMachine, globalObject: *JSGlobalObjec
bun.api.node.process.exit(globalObject, 1);
@panic("made it past Bun__Process__exit");
}
// Try to handle missing module in watch mode before exiting
this.tryHandleMissingModuleInWatchMode(globalObject, err);
this.is_handling_uncaught_exception = true;
defer this.is_handling_uncaught_exception = false;
const handled = Bun__handleUncaughtException(globalObject, err.toError() orelse err, if (is_rejection) 1 else 0) > 0;
@@ -680,7 +729,12 @@ pub fn uncaughtException(this: *jsc.VirtualMachine, globalObject: *JSGlobalObjec
pub fn handlePendingInternalPromiseRejection(this: *jsc.VirtualMachine) void {
var promise = this.pending_internal_promise.?;
if (promise.status(this.global.vm()) == .rejected and !promise.isHandled(this.global.vm())) {
this.unhandledRejection(this.global, promise.result(this.global.vm()), promise.asValue());
const rejection_value = promise.result(this.global.vm());
// Try to handle missing module in watch mode
this.tryHandleMissingModuleInWatchMode(this.global, rejection_value);
this.unhandledRejection(this.global, rejection_value, promise.asValue());
promise.setHandled(this.global.vm());
}
}

View File

@@ -52,6 +52,9 @@ pub const All = struct {
/// Updates the "Date" header.
date_header_timer: DateHeaderTimer = .{},
/// Polls for missing modules in watch mode
missing_module_poll_timer: MissingModulePollTimer = MissingModulePollTimer.init(),
pub fn init() @This() {
return .{
.thread_id = std.Thread.getCurrentId(),
@@ -602,6 +605,8 @@ pub const DateHeaderTimer = @import("./Timer/DateHeaderTimer.zig");
pub const EventLoopDelayMonitor = @import("./Timer/EventLoopDelayMonitor.zig");
pub const MissingModulePollTimer = @import("./Timer/MissingModulePollTimer.zig");
pub const internal_bindings = struct {
/// Node.js has some tests that check whether timers fire at the right time. They check this
/// with the internal binding `getLibuvNow()`, which returns an integer in milliseconds. This

View File

@@ -69,6 +69,7 @@ pub const Tag = enum {
DateHeaderTimer,
BunTest,
EventLoopDelayMonitor,
MissingModulePollTimer,
pub fn Type(comptime T: Tag) type {
return switch (T) {
@@ -94,6 +95,7 @@ pub const Tag = enum {
.DateHeaderTimer => jsc.API.Timer.DateHeaderTimer,
.BunTest => jsc.Jest.bun_test.BunTest,
.EventLoopDelayMonitor => jsc.API.Timer.EventLoopDelayMonitor,
.MissingModulePollTimer => jsc.API.Timer.MissingModulePollTimer,
};
}
};
@@ -188,6 +190,10 @@ pub fn fire(self: *Self, now: *const timespec, vm: *VirtualMachine) Arm {
monitor.onFire(vm, now);
return .disarm;
},
.MissingModulePollTimer => {
const poll_timer = @as(*jsc.API.Timer.MissingModulePollTimer, @fieldParentPtr("event_loop_timer", self));
return poll_timer.onTimeout(vm);
},
inline else => |t| {
if (@FieldType(t.Type(), "event_loop_timer") != Self) {
@compileError(@typeName(t.Type()) ++ " has wrong type for 'event_loop_timer'");

View File

@@ -0,0 +1,146 @@
/// MissingModulePollTimer manages polling for missing modules in watch mode.
///
/// When a script tries to import a file that doesn't exist, Bun's watch mode should
/// wait for the file to be created rather than exiting with an error. This timer
/// implements exponential backoff polling to check if the missing file has appeared.
///
/// Behavior:
/// - Starts at 2ms intervals, exponentially backs off to max 100ms
/// - Checks if the missing file exists on each poll
/// - Triggers a process reload when the file appears
/// - Stops polling if watch mode is disabled or VM is shutting down
const MissingModulePollTimer = @This();
event_loop_timer: jsc.API.Timer.EventLoopTimer = .{
.tag = .MissingModulePollTimer,
.next = .epoch,
},
/// The path to the missing module that we're polling for
missing_path: []const u8 = &.{},
/// Current polling interval in milliseconds
current_interval_ms: u32 = 2,
/// Minimum polling interval (2ms)
min_interval_ms: u32 = 2,
/// Maximum polling interval (100ms)
max_interval_ms: u32 = 100,
/// Whether the timer is actively polling
is_polling: bool = false,
pub fn init() MissingModulePollTimer {
return .{};
}
/// Start polling for a missing module
pub fn startPolling(this: *MissingModulePollTimer, vm: *VirtualMachine, missing_path: []const u8) !void {
// If already polling, stop the current timer first
if (this.is_polling) {
this.stopPolling(vm);
}
// Store a copy of the path
if (this.missing_path.len > 0) {
bun.default_allocator.free(this.missing_path);
}
this.missing_path = try bun.default_allocator.dupe(u8, missing_path);
// Reset interval to minimum
this.current_interval_ms = this.min_interval_ms;
this.is_polling = true;
// Schedule the first poll
this.scheduleNextPoll(vm);
log("Started polling for missing module: {s} (interval: {}ms)", .{ this.missing_path, this.current_interval_ms });
}
/// Stop polling for the missing module
pub fn stopPolling(this: *MissingModulePollTimer, vm: *VirtualMachine) void {
if (!this.is_polling) return;
if (this.event_loop_timer.state == .ACTIVE) {
vm.timer.remove(&this.event_loop_timer);
}
this.is_polling = false;
this.current_interval_ms = this.min_interval_ms;
log("Stopped polling for missing module: {s}", .{this.missing_path});
}
/// Schedule the next poll with the current interval
fn scheduleNextPoll(this: *MissingModulePollTimer, vm: *VirtualMachine) void {
this.event_loop_timer.next = bun.timespec.msFromNow(@intCast(this.current_interval_ms));
vm.timer.insert(&this.event_loop_timer);
}
/// Timer callback that checks if the missing file exists
pub fn onTimeout(this: *MissingModulePollTimer, vm: *VirtualMachine) jsc.API.Timer.EventLoopTimer.Arm {
this.event_loop_timer.state = .FIRED;
if (!this.is_polling) {
return .disarm;
}
// Check if the file exists
const file_exists = this.checkFileExists();
if (file_exists) {
log("Missing module found: {s}. Triggering reload.", .{this.missing_path});
// Stop polling
this.is_polling = false;
// Trigger a hot reload by calling reload directly
const HotReloader = jsc.hot_reloader.HotReloader;
var task = HotReloader.Task.initEmpty(undefined);
vm.reload(&task);
return .disarm;
}
// File still doesn't exist, increase interval with exponential backoff
this.current_interval_ms = @min(this.current_interval_ms * 2, this.max_interval_ms);
log("Missing module not found yet: {s}. Next poll in {}ms", .{ this.missing_path, this.current_interval_ms });
// Schedule next poll
this.scheduleNextPoll(vm);
return .disarm;
}
/// Check if the file exists
fn checkFileExists(this: *MissingModulePollTimer) bool {
if (this.missing_path.len == 0) return false;
// Use stat to check if the file exists
const stat_result = std.fs.cwd().statFile(this.missing_path) catch return false;
return stat_result.kind == .file;
}
/// Cleanup timer resources
pub fn deinit(this: *MissingModulePollTimer, vm: *VirtualMachine) void {
if (this.event_loop_timer.state == .ACTIVE) {
vm.timer.remove(&this.event_loop_timer);
}
if (this.missing_path.len > 0) {
bun.default_allocator.free(this.missing_path);
this.missing_path = &.{};
}
this.is_polling = false;
}
const log = bun.Output.scoped(.MissingModulePollTimer, .hidden);
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;
const VirtualMachine = jsc.VirtualMachine;

View File

@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { writeFileSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("watch mode should poll for missing module and reload when it appears", async () => {
using dir = tempDir("watch-missing-external", {
"entry.js": `
console.log("Starting...");
require("./missing.js");
console.log("This should not print on first run");
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "entry.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait a bit for the process to start and hit the error
await new Promise(resolve => setTimeout(resolve, 500));
// Now write the missing file from outside the process
writeFileSync(join(String(dir), "missing.js"), "console.log('Module loaded!'); process.exit(0);");
const timeout = setTimeout(() => {
proc.kill();
}, 5000);
try {
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
clearTimeout(timeout);
console.log("STDOUT:", stdout);
console.log("STDERR:", stderr);
console.log("Exit code:", exitCode);
// The test passes if the process exits with code 0
expect(exitCode).toBe(0);
expect(stdout).toContain("Module loaded!");
} catch (err) {
clearTimeout(timeout);
proc.kill();
throw err;
}
}, 10000);

View File

@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("watch mode should detect missing module error", async () => {
using dir = tempDir("watch-missing-simple", {
"entry.js": `
console.log("Starting...");
try {
require("./missing.js");
} catch (err) {
console.log("ERROR:", err.code);
console.log("Caught error for missing module");
const fs = require("fs");
const path = require("path");
// Write the missing file after a short delay
setTimeout(() => {
console.log("Writing missing.js");
fs.writeFileSync(path.join(__dirname, "missing.js"), "console.log('Module loaded!'); process.exit(0);");
}, 100);
// Re-throw so watch mode sees it
throw err;
}
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "entry.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => {
proc.kill();
}, 5000);
try {
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
clearTimeout(timeout);
console.log("STDOUT:", stdout);
console.log("STDERR:", stderr);
console.log("Exit code:", exitCode);
// The test passes if the process exits with code 0
expect(exitCode).toBe(0);
} catch (err) {
clearTimeout(timeout);
proc.kill();
throw err;
}
}, 10000);

View File

@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("watch mode should poll for missing modules", async () => {
using dir = tempDir("watch-missing-module", {
"entry.js": `
try {
require("./missing.js");
} catch (err) {
// Write the missing file
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(__dirname, "missing.js"), "process.exit(0);");
// Re-throw so watch mode sees the error and starts polling
throw err;
}
// If we get here without the file existing, something went wrong
setTimeout(() => {
process.exit(1);
}, 5000);
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "entry.js"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => {
proc.kill();
}, 10000);
try {
const exitCode = await proc.exited;
clearTimeout(timeout);
// The test passes if the process exits with code 0
// This means the missing file was created and executed successfully
expect(exitCode).toBe(0);
} catch (err) {
clearTimeout(timeout);
proc.kill();
throw err;
}
}, 15000);
test("watch mode should poll for missing modules with import", async () => {
using dir = tempDir("watch-missing-module-import", {
"entry.mjs": `
try {
await import("./missing.mjs");
} catch (err) {
// Write the missing file
const fs = await import("fs");
const path = await import("path");
const url = await import("url");
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
fs.writeFileSync(path.join(__dirname, "missing.mjs"), "process.exit(0);");
// Re-throw so watch mode sees the error and starts polling
throw err;
}
// If we get here without the file existing, something went wrong
setTimeout(() => {
process.exit(1);
}, 5000);
`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "entry.mjs"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => {
proc.kill();
}, 10000);
try {
const exitCode = await proc.exited;
clearTimeout(timeout);
// The test passes if the process exits with code 0
expect(exitCode).toBe(0);
} catch (err) {
clearTimeout(timeout);
proc.kill();
throw err;
}
}, 15000);