mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
Compare commits
2 Commits
claude/fix
...
claude/wat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054e5b2e72 | ||
|
|
e7c624e773 |
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'");
|
||||
|
||||
146
src/bun.js/api/Timer/MissingModulePollTimer.zig
Normal file
146
src/bun.js/api/Timer/MissingModulePollTimer.zig
Normal 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;
|
||||
50
test/cli/watch/watch-missing-external.test.ts
Normal file
50
test/cli/watch/watch-missing-external.test.ts
Normal 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);
|
||||
56
test/cli/watch/watch-missing-module-simple.test.ts
Normal file
56
test/cli/watch/watch-missing-module-simple.test.ts
Normal 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);
|
||||
99
test/cli/watch/watch-missing-module.test.ts
Normal file
99
test/cli/watch/watch-missing-module.test.ts
Normal 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);
|
||||
Reference in New Issue
Block a user