Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 245 additions & 2 deletions src/bake/DevServer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,100 @@ pub const debug = bun.Output.Scoped(.DevServer, .visible);
pub const igLog = bun.Output.scoped(.IncrementalGraph, .visible);
pub const mapLog = bun.Output.scoped(.SourceMapStore, .visible);

/// macOS-specific structure to track deleted entrypoints
/// Since macOS watches file descriptors (not paths), we need special handling for deleted files
pub const DeletedEntrypointWatchlist = struct {
const Entry = struct {
/// Absolute path to the deleted entrypoint file, allocated using
/// `dev.allocator`
abs_path: [:0]const u8,

pub fn deinit(self: *Entry, allocator: Allocator) void {
allocator.free(self.abs_path);
}
};

/// parent directory -> list of deleted entries in that directory
entries_by_dir: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Entry)) = .{},

/// Should be `dev.allocator`
allocator: Allocator,

pub fn init(allocator: Allocator) DeletedEntrypointWatchlist {
return .{
.allocator = allocator,
.entries_by_dir = .{},
};
}

pub fn deinit(self: *DeletedEntrypointWatchlist) void {
var map = &self.entries_by_dir;
var iter = map.iterator();
while (iter.next()) |kv| {
self.allocator.free(kv.key_ptr.*);
for (kv.value_ptr.items) |*entry| {
entry.deinit(self.allocator);
}
kv.value_ptr.deinit(self.allocator);
}
map.deinit(self.allocator);
}

pub fn add(self: *DeletedEntrypointWatchlist, abs_path: []const u8) !void {
const abs_path_copy = try self.allocator.dupeZ(u8, abs_path);
errdefer self.allocator.free(abs_path_copy);

const parent_dir = std.fs.path.dirname(abs_path) orelse abs_path;

// Get or create the entry list for this directory
const gop = try self.entries_by_dir.getOrPut(self.allocator, parent_dir);
if (!gop.found_existing) {
// New directory, need to copy the key
const parent_dir_copy = try self.allocator.dupe(u8, parent_dir);
gop.key_ptr.* = parent_dir_copy;
gop.value_ptr.* = .{};
}

try gop.value_ptr.append(self.allocator, .{
.abs_path = abs_path_copy,
});

debug.log("Added deleted entrypoint to watchlist: {s} (parent: {s})", .{ abs_path, parent_dir });
}

pub fn removeEntry(self: *DeletedEntrypointWatchlist, parent_dir: []const u8, abs_path: []const u8) void {
const entry_list = self.entries_by_dir.getPtr(parent_dir) orelse return;

for (entry_list.items, 0..) |*entry, i| {
if (bun.strings.eql(entry.abs_path, abs_path)) {
entry.deinit(self.allocator);
_ = entry_list.swapRemove(i);

// Also free if it is the last entry in this directory
if (entry_list.items.len == 0) {
entry_list.deinit(self.allocator);
const kv = self.entries_by_dir.fetchRemove(parent_dir).?;
self.allocator.free(kv.key);
}
break;
}
}
}

pub fn getEntriesForDirectory(self: *DeletedEntrypointWatchlist, dir_path: []const u8) ?*ArrayListUnmanaged(Entry) {
const entry_list = self.entries_by_dir.getPtr(dir_path) orelse return null;
return entry_list;
}

pub fn clearIfEmpty(self: *DeletedEntrypointWatchlist, entries: *ArrayListUnmanaged(Entry), dir_path: []const u8) void {
if (entries.items.len == 0) {
entries.deinit(self.allocator);
const kv = self.entries_by_dir.fetchRemove(dir_path).?;
self.allocator.free(kv.key);
}
}
};

pub const Options = struct {
/// Arena must live until DevServer.deinit()
arena: Allocator,
Expand Down Expand Up @@ -110,6 +204,9 @@ server_register_update_callback: jsc.Strong.Optional,
bun_watcher: *bun.Watcher,
directory_watchers: DirectoryWatchStore,
watcher_atomics: WatcherAtomics,
/// macOS-specific: Track deleted entrypoints since we can't watch file descriptors for deleted files
/// Thread-safe access since onFileUpdate runs on watcher thread
deleted_entrypoints: if (bun.Environment.isMac) bun.threading.GuardedValue(DeletedEntrypointWatchlist, bun.Mutex) else void,
/// In end-to-end DevServer tests, flakiness was noticed around file watching
/// and bundling times, where the test harness (bake-harness.ts) would not wait
/// long enough for processing to complete. Checking client logs, for example,
Expand Down Expand Up @@ -328,6 +425,7 @@ pub fn init(options: Options) bun.JSOOM!*DevServer {
.client_transpiler = undefined,
.ssr_transpiler = undefined,
.bun_watcher = undefined,
.deleted_entrypoints = undefined,
.configuration_hash_key = undefined,
.router = undefined,
.watcher_atomics = undefined,
Expand All @@ -339,6 +437,12 @@ pub fn init(options: Options) bun.JSOOM!*DevServer {
dev.allocator = allocator;
dev.log = .init(allocator);
dev.deferred_request_pool = .init(allocator);
dev.deleted_entrypoints = if (bun.Environment.isMac)
bun.threading.GuardedValue(DeletedEntrypointWatchlist, bun.Mutex).init(
DeletedEntrypointWatchlist.init(dev.allocator),
.{},
)
else {};

const global = dev.vm.global;

Expand Down Expand Up @@ -580,6 +684,13 @@ pub fn deinit(dev: *DevServer) void {
dev.vm.timer.remove(&dev.memory_visualizer_timer),
.graph_safety_lock = dev.graph_safety_lock.lock(),
.bun_watcher = dev.bun_watcher.deinit(true),
.deleted_entrypoints = if (bun.Environment.isMac) blk: {
// Get the value and deinit it
const list_ptr = dev.deleted_entrypoints.lock();
defer dev.deleted_entrypoints.unlock();
list_ptr.deinit();
break :blk {};
} else {},
.dump_dir = if (bun.FeatureFlags.bake_debugging_features) if (dev.dump_dir) |*dir| dir.close(),
.log = dev.log.deinit(),
.server_fetch_function_callback = dev.server_fetch_function_callback.deinit(),
Expand Down Expand Up @@ -1320,6 +1431,15 @@ fn onHtmlRequestWithBundle(dev: *DevServer, route_bundle_index: RouteBundle.Inde
assert(route_bundle.data == .html);
const html = &route_bundle.data.html;

// Check if the HTML file was deleted (script_injection_offset would be null)
if (html.script_injection_offset.unwrap() == null or html.bundled_html_text == null) {
// The HTML file was deleted, return a 404 error
debug.log("HTML route requested but file was deleted: route_bundle_index={d}", .{route_bundle_index.get()});

sendBuiltInNotFound(resp);
return;
}

const blob = html.cached_response orelse generate: {
const payload = generateHTMLPayload(dev, route_bundle_index, route_bundle, html) catch bun.outOfMemory();
errdefer dev.allocator.free(payload);
Expand Down Expand Up @@ -1353,8 +1473,15 @@ fn generateHTMLPayload(dev: *DevServer, route_bundle_index: RouteBundle.Index, r
assert(route_bundle.server_state == .loaded); // if not loaded, following values wont be initialized
assert(html.html_bundle.data.dev_server_id.unwrap() == route_bundle_index);
assert(html.cached_response == null);
const script_injection_offset = (html.script_injection_offset.unwrap() orelse unreachable).get();
const bundled_html = html.bundled_html_text orelse unreachable;

// This should be checked before calling generateHTMLPayload, but let's be defensive
const script_injection_offset = (html.script_injection_offset.unwrap() orelse {
@panic("generateHTMLPayload called with null script_injection_offset");
}).get();

const bundled_html = html.bundled_html_text orelse {
@panic("generateHTMLPayload called with null bundled_html_text");
};

// The bundler records an offsets in development mode, splitting the HTML
// file into two chunks. DevServer is able to insert style/script tags
Expand Down Expand Up @@ -2723,6 +2850,8 @@ pub fn handleParseTaskFailure(
});

if (err == error.FileNotFound or err == error.ModuleNotFound) {
dev.handleEntrypointNotFoundIfNeeded(abs_path, graph, bv2);

// Special-case files being deleted. Note that if a file had never
// existed, resolution would fail first.
switch (graph) {
Expand All @@ -2738,6 +2867,85 @@ pub fn handleParseTaskFailure(
}
}

/// We rely a lot on the files we parse in the bundle graph to know which files
/// to add to the watcher.
///
/// There is one wrinkle with this:
///
/// If an entrypoint is deleted it will never get parsed and then never will be
/// watched
///
/// So if we get `error.FileNotFound` on an entrypoint, we'll manually add it to
/// the watcher to pick up if it got changed again.
fn handleEntrypointNotFoundIfNeeded(dev: *DevServer, abs_path: []const u8, graph_kind: bake.Graph, bv2: *BundleV2) void {
_ = bv2;
const fd, const loader = switch (graph_kind) {
.server, .ssr => out: {
const graph = &dev.server_graph;
const index = graph.bundled_files.getIndex(abs_path) orelse return;
const loader = bun.options.Loader.fromString(abs_path) orelse bun.options.Loader.file;
const file = &graph.bundled_files.values()[index];
if (file.is_route) {
break :out .{ bun.invalid_fd, loader };
}
return;
},
.client => out: {
const graph = &dev.client_graph;
const index = graph.bundled_files.getIndex(abs_path) orelse return;
const loader = bun.options.Loader.fromString(abs_path) orelse bun.options.Loader.file;
const file = &graph.bundled_files.values()[index];
if (file.flags.is_html_route or file.flags.is_hmr_root) {
// const dirname = std.fs.path.dirname(abs_path) orelse abs_path;
// if (bv2.transpiler.resolver.fs.fs.entries.get(dirname)) |entry| {
// const data = entry.entries.data;
// std.debug.print("LEN: {d}\n", .{data.size});
// }
break :out .{ bun.invalid_fd, loader };
}

return;
},
};

// macOS watches on file descriptors, but we may not have a open file handle
// to the deleted file... We need to add it to a list and have the watcher
// special case it
if (comptime bun.Environment.isMac) {
// Add to deleted entrypoints watchlist (thread-safe)
const list_ptr = dev.deleted_entrypoints.lock();
defer dev.deleted_entrypoints.unlock();

list_ptr.add(abs_path) catch bun.outOfMemory();

// Also watch the parent directory for changes
// TODO: is this needed?
const parent_dir = std.fs.path.dirname(abs_path) orelse abs_path;
_ = dev.bun_watcher.addDirectory(
bun.invalid_fd,
parent_dir,
bun.hash32(parent_dir),
true,
);

return;
}

// Linux watches on file paths, so we can just add the deleted file to the
// watcher here
//
// I think this is the same on Windows?
_ = dev.bun_watcher.addFile(
fd,
abs_path,
bun.hash32(abs_path),
loader,
bun.invalid_fd,
null,
true,
);
}

/// Return a log to write resolution failures into.
pub fn getLogForResolutionFailures(dev: *DevServer, abs_path: []const u8, graph: bake.Graph) !*bun.logger.Log {
assert(dev.current_bundle != null);
Expand Down Expand Up @@ -3703,6 +3911,41 @@ pub fn onFileUpdate(dev: *DevServer, events: []Watcher.Event, changed_files: []?
ev.appendFile(dev.allocator, file_path);
},
.directory => {
// macOS watches on FDs, not paths. So if an entrypoint is
// deleted we lose its file descriptor. What do we do then?
// We'll check if its parent directory changed and test to see
// if the file is back again.
if (comptime bun.Environment.isMac) {
const deleted_watchlist_for_directory = dev.deleted_entrypoints.lock();
defer dev.deleted_entrypoints.unlock();

if (deleted_watchlist_for_directory.getEntriesForDirectory(file_path)) |entries| {
var i: usize = 0;
while (i < entries.items.len) {
const entry = &entries.items[i];

const stat = bun.sys.stat(entry.abs_path);
if (stat == .err) {
// File still doesn't exist
i += 1;
continue;
}

debug.log("Deleted entrypoint recreated: {s}", .{entry.abs_path});

const abs_path = entry.abs_path;
entry.abs_path = "";

_ = entries.swapRemove(i);

// Append the file to the event so it will be reprocessed again in HotReloadEvent.processFileList
ev.appendFile(dev.allocator, abs_path);
}

deleted_watchlist_for_directory.clearIfEmpty(entries, file_path);
}
}

// INotifyWatcher stores sub paths into `changed_files`
// the other platforms do not appear to write anything into `changed_files` ever.
if (Environment.isLinux) {
Expand Down
59 changes: 59 additions & 0 deletions test/bake/dev/vim-file-swap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { devTest } from "../bake-harness";

devTest("vim file swap hot reload for entrypoints", {
files: {
"index.html": `<!DOCTYPE html>
<head>
<title>Test</title>
</head>
<body>
<p>Test foo</p>
<script type="module" src="index.ts"></script>
</body>`,
"index.ts": ``,
},
async test(dev) {
await using c = await dev.client("/");

// Verify initial load works
const initialResponse = await dev.fetch("/");
expect(initialResponse.status).toBe(200);
const initialText = await initialResponse.text();
expect(initialText).toContain("Test foo");

// Simulate vim-style file editing multiple times to increase reliability
for (let i = 0; i < 3; i++) {
const updatedContent = `<!DOCTYPE html>
<head>
<title>Test</title>
</head>
<body>
<p>Test bar ${i + 1}</p>
<script type="module" src="index.ts"></script>
</body>`;

// Step 1: Create .index.html.swp file with new content
const swapFile = path.join(dev.rootDir, ".index.html.swp");
await Bun.file(swapFile).write(updatedContent);

// Step 2: Delete original index.html
const originalFile = path.join(dev.rootDir, "index.html");
fs.unlinkSync(originalFile);

// Step 3: Rename .index.html.swp to index.html
fs.cpSync(swapFile, originalFile);

// Wait a bit for file watcher to detect changes
await new Promise(resolve => setTimeout(resolve, 100));

// Verify the content was updated
const response = await dev.fetch("/");
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain(`Test bar ${i + 1}`);
}
},
});