From aba2bc6948e203a8f951ad05682ec246cce29412 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 9 Oct 2026 12:09:58 -0500 Subject: [PATCH 1/2] app: a rebuilt plugin loads without freezing a frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin rebuilt under a running fizzy used to be reloaded on the UI thread, and its `dlopen` — on macOS 180-220 ms for a file the system has not seen, which a rebuild always is — froze the person's frame for that long. The `dlopen` was the whole cost: the plugin's `register` takes ~30 us. `PluginLoader` splits into `open` (copy, `dlopen`, ABI and SDK checks; nothing of the host's, so any thread) and `register` (on the UI thread); `loadAndRegister` is the two in a row, so startup is unchanged. The watcher's reload (`PluginReloads`) opens the new build on a worker while the old one keeps running, and a later frame swaps them: ~8 ms on the UI thread (unload ~4 ms, register ~4 ms), the ~175 ms open off it. Part of #250. Co-Authored-By: Claude Opus 5.5 --- app/store/PluginLoader.zig | 79 +++++++++++++---- scripts/plugin-loop/bench.py | 11 +-- src/editor/Editor.zig | 54 ++++++++---- src/editor/PluginReloads.zig | 165 +++++++++++++++++++++++++++++++++++ 4 files changed, 269 insertions(+), 40 deletions(-) create mode 100644 src/editor/PluginReloads.zig diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index a0ad11d23..ab6f9a585 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -329,6 +329,28 @@ pub fn sweepLoadTempDir(allocator: std.mem.Allocator, plugins_dir: []const u8) v } } +/// A plugin binary opened and checked, not yet registered — everything `loadAndRegister` does +/// that touches no host state, so `open` runs on any thread. The `dlopen` in it is what a load +/// costs (on macOS ~180 ms for a file the system has not seen, a rebuilt plugin every time); +/// `register` finishes it on the UI thread in microseconds. Dropped unregistered with `close`. +pub const Opened = struct { + lib: DynLib, + path: []const u8, + plugin_id: []const u8, + src_stat: ?std.Io.File.Stat, + version_info: PluginVersionInfo, + set_globals: dylib_api.SetGlobalsFn, + register_fn: *const fn (?*Host) callconv(.c) u32, + set_dvui_context: dvui_context.SetContextFn, + set_render_bridge: sdk.render_bridge.SetRenderBridgeFn, + + pub fn close(self: *Opened) void { + self.lib.close(); + } +}; + +/// `open` then `register`, on the calling thread: startup's load, and any other that does not +/// mind the frame. pub fn loadAndRegister( host: *Host, allocator: std.mem.Allocator, @@ -336,6 +358,13 @@ pub fn loadAndRegister( expected_id: []const u8, pre: ?PreRegister, ) LoadError!LoadedLib { + return register(host, try open(allocator, path, expected_id), pre); +} + +/// Open the plugin at `path` and check it is one this host can load: its ABI fingerprint, the +/// SDK it needs, the id it declares, the symbols registration calls. Safe on any thread: it reads +/// the file and the image, and nothing of the host's. `allocator` must be too. +pub fn open(allocator: std.mem.Allocator, path: []const u8, expected_id: []const u8) LoadError!Opened { // First open of this path in this process: no stale-image risk, so load the installed file // directly and skip the copy (and, on macOS, the ~110ms first-open validation a brand-new // file would cost). Any later open of the same path — a reload after the store wrote a new @@ -424,15 +453,38 @@ pub fn loadAndRegister( dylib_api.symbol_set_render_bridge, ) orelse return error.SetRenderBridgeSymbolMissing; + return .{ + .lib = lib, + .path = path, + .plugin_id = expected_id, + .src_stat = src_stat, + .version_info = .{ + .plugin_version = plugin_version, + .built_with_sdk_version = built_with, + .min_sdk_version = min_sdk, + .declared_id = if (get_plugin_id) |f| std.mem.span(f()) else null, + }, + .set_globals = set_globals, + .register_fn = reg_fn, + .set_dvui_context = set_ctx, + .set_render_bridge = set_bridge, + }; +} + +/// Register an `open`ed plugin with `host`, `pre`'s pointers injected first. On the UI thread. +/// Takes `opened` either way: on failure its image is closed. +pub fn register(host: *Host, opened: Opened, pre: ?PreRegister) LoadError!LoadedLib { + var o = opened; + errdefer o.close(); if (pre) |inject| { - set_globals( + o.set_globals( if (inject.gpa) |gpa| @ptrCast(gpa) else null, inject.arg_b, inject.arg_c, ); } - const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host)); + const status: dylib_api.RegisterStatus = @enumFromInt(o.register_fn(host)); switch (status) { .ok => {}, .err_abi_mismatch => return error.AbiMismatch, @@ -441,20 +493,15 @@ pub fn loadAndRegister( } return .{ - .lib = lib, - .path = path, - .plugin_id = expected_id, - .source_mtime_ns = if (src_stat) |st| st.mtime.nanoseconds else 0, - .source_size = if (src_stat) |st| st.size else 0, - .version_info = .{ - .plugin_version = plugin_version, - .built_with_sdk_version = built_with, - .min_sdk_version = min_sdk, - .declared_id = if (get_plugin_id) |f| std.mem.span(f()) else null, - }, - .set_globals = set_globals, - .set_dvui_context = set_ctx, - .set_render_bridge = set_bridge, + .lib = o.lib, + .path = o.path, + .plugin_id = o.plugin_id, + .source_mtime_ns = if (o.src_stat) |st| st.mtime.nanoseconds else 0, + .source_size = if (o.src_stat) |st| st.size else 0, + .version_info = o.version_info, + .set_globals = o.set_globals, + .set_dvui_context = o.set_dvui_context, + .set_render_bridge = o.set_render_bridge, }; } diff --git a/scripts/plugin-loop/bench.py b/scripts/plugin-loop/bench.py index 9e34daa1e..51bf405b9 100755 --- a/scripts/plugin-loop/bench.py +++ b/scripts/plugin-loop/bench.py @@ -11,7 +11,8 @@ each with Zig's per-step times (compile, install). reload with `--app `: a sandbox fizzy runs on that profile, and each edit and rebuild is picked up by its plugin watcher. fizzy logs how long after the binary was written it - noticed, and how long the swap took (unload, load) — `Editor.updatePlugin`. + noticed, how long the new binary took to open off the UI thread, and how long the swap + on the UI thread took (unload, register) — `PluginReloads`. The plugin is copied to `zig-out/plugin-loop/` first, so the repo's copy is never edited, and it installs into a scratch profile, never the real plugins directory. With `--app`, a fizzy window @@ -114,10 +115,10 @@ def bench_reload(plugin_dir: str, optimize: str, profile: str, app: str, edits: sys.exit(f"fizzy never reloaded the rebuild — see {log_path}") total = (time.monotonic() - start) * 1000 noticed = last(log_path, r"noticed ([\d.]+)ms after it was written") - swap = last(log_path, r"swapped in ([\d.]+)ms \(unload ([\d.]+)ms, load ([\d.]+)ms\)") + swap = last(log_path, r"swapped in ([\d.]+)ms on the UI thread \(unload ([\d.]+)ms, register ([\d.]+)ms\), opened off it in ([\d.]+)ms") print(f" edit {n}: build {fmt(wall)} (compile {fmt(steps.get('compile', 0))}), " - f"noticed {noticed[0]} ms after written, swap {swap[0]} ms " - f"(unload {swap[1]}, load {swap[2]}) — {fmt(total)} from the edit to running") + f"noticed {noticed[0]} ms after written, opened off the UI thread in {swap[3]} ms, " + f"swap on it {swap[0]} ms (unload {swap[1]}, register {swap[2]}) — {fmt(total)} from the edit to running") finally: proc.send_signal(signal.SIGKILL) proc.wait() @@ -143,7 +144,7 @@ def last(path: str, pattern: str) -> tuple: with open(path, errors="replace") as f: found = re.findall(pattern, f.read()) if not found: - return ("?", "?", "?") + return ("?", "?", "?", "?") return found[-1] if isinstance(found[-1], tuple) else (found[-1],) diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 5ebb2934a..db6ed0f17 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -124,6 +124,9 @@ command_palette: @import("CommandPalette.zig") = .{}, /// address (the stage's context is this field). demo: Demo = .detached, +/// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). +plugin_reloads: @import("PluginReloads.zig") = .{}, + explorer: *Explorer, last_titlebar_color: dvui.Color, @@ -1364,18 +1367,26 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; const path = try App.userPluginPath(editor.app.gpa, &editor.app, id); - errdefer editor.app.gpa.free(path); + const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { + editor.userPluginLoadFailed(id, path, err); + editor.app.gpa.free(path); + return err; + }; + return editor.finishUserPluginLoad(id, opened); +} - const loaded = PluginLoader.loadAndRegister(&editor.app.host, editor.app.gpa, path, id, .{ +/// The UI-thread half of loading a user plugin: register an `open`ed binary and wire it into the +/// app. Takes `opened` and its path (gpa-owned) either way — `PluginReloads` opens a rebuild off +/// the UI thread and finishes it here. +pub fn finishUserPluginLoad(editor: *Editor, id: []const u8, opened: PluginLoader.Opened) !void { + const path = opened.path; + errdefer editor.app.gpa.free(path); + const loaded = PluginLoader.register(&editor.app.host, opened, .{ .gpa = &editor.app.gpa, .arg_b = @ptrCast(&editor.app.host), .arg_c = null, }) catch |err| { - // Leave the same actionable record the startup scan leaves (see `recordLoadFailure`), so - // a build that fails a live load stays visible in the store's installed pane with its - // Reinstall/Uninstall controls instead of disappearing until the next restart. - dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ id, path, @errorName(err), App.pluginLoadFailureReason(err) }); - editor.app.recordLoadFailure(id, path, err); + editor.userPluginLoadFailed(id, path, err); return err; }; try editor.app.appendLoadedPluginLib(loaded); @@ -1394,6 +1405,14 @@ pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { editor.app.clearFailedUserPlugin(id); } +/// Leave the same actionable record the startup scan leaves (see `recordLoadFailure`), so a build +/// that fails a live load stays visible in the store's installed pane with its Reinstall/Uninstall +/// controls instead of disappearing until the next restart. +fn userPluginLoadFailed(editor: *Editor, id: []const u8, path: []const u8, err: PluginLoader.LoadError) void { + dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ id, path, @errorName(err), App.pluginLoadFailureReason(err) }); + editor.app.recordLoadFailure(id, path, err); +} + /// What the web load/update pair can fail with. Spelled out rather than inferred: the two call /// each other (a request for an id already running is an update), and inferred sets cannot. pub const WebLoadError = UnloadError || error{ OutOfMemory, NotUnloadable }; @@ -3331,18 +3350,11 @@ pub fn reconcileChangedPluginBinaries(editor: *Editor) void { if (newest_write) |written| dvui.log.info("plugin watcher: a rebuilt binary noticed {d:.1}ms after it was written", .{ msBetween(written, std.Io.Clock.real.now(dvui.io).nanoseconds), }); - for (changed.items) |id| { - if (editor.updatePlugin(id, false)) { - dvui.log.info("plugin watcher: reloaded '{s}' from its rebuilt binary", .{id}); - } else |err| { - dvui.log.warn("plugin watcher: could not reload rebuilt '{s}' ({s})", .{ id, @errorName(err) }); - // Re-stamp so a plugin we chose not to reload (unsaved documents, most likely) doesn't - // re-trigger on every subsequent watcher event. The next rebuild moves the stamp again - // and gets a fresh attempt; until then the running build stays, which is what the - // failed unload already decided. - editor.app.restampLoadedPlugin(id); - } - } + // Opened off the UI thread and swapped in on a later frame (`PluginReloads`): the `dlopen` of a + // rebuilt binary is ~180 ms on macOS, and here it froze the person's frame. A swap the plugin + // refuses (unsaved documents, most likely) re-stamps it there, so it does not re-trigger on + // every later watcher event; the next rebuild gets a fresh attempt. + for (changed.items) |id| editor.plugin_reloads.start(editor, id); } fn msBetween(from_ns: i96, to_ns: i96) f64 { @@ -3755,6 +3767,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Pick up any external edit to settings.zon (see R11 in docs/PLUGIN_MANIFEST_PLAN.md). // Cheap no-op unless the watcher thread actually saw a change. if (editor.app.settings_watcher) |*w| w.tick(editor.configWatchSink()); + // A rebuilt plugin opened off the UI thread since the last frame is swapped in now. + editor.plugin_reloads.frame(editor); // Reload clean open docs / flag dirty conflicts when files change on disk. if (editor.document_watcher) |*w| w.tick(editor); @@ -5756,6 +5770,8 @@ pub fn deinit(editor: *Editor) !void { w.stop(); editor.app.settings_watcher = null; } + // Rebuilds still opening: their threads joined, what they opened dropped unregistered. + editor.plugin_reloads.deinit(editor.app.gpa); // Before the plugin `deinit` loop below: `tick` fans out into plugin vtables, and this // joins the thread that feeds it. if (editor.app.folder_watcher) |*w| { diff --git a/src/editor/PluginReloads.zig b/src/editor/PluginReloads.zig new file mode 100644 index 000000000..3bc9f5740 --- /dev/null +++ b/src/editor/PluginReloads.zig @@ -0,0 +1,165 @@ +//! Rebuilt plugins swapped in without freezing a frame. +//! +//! When the plugin watcher sees a loaded plugin's binary change (`reconcileChangedPluginBinaries`), +//! the new build is opened on a worker thread (`PluginLoader.open`: the copy, the `dlopen`, the +//! ABI checks) while the old one keeps running. Only once it is open does a frame swap them: the +//! old unloaded, the new registered (`Editor.finishUserPluginLoad`). The `dlopen` was the whole +//! cost of a reload — on macOS ~180 ms for a file the system has not seen, which a rebuild always +//! is — and it was paid on the UI thread, freezing the person's frame; the swap left on it is a few +//! milliseconds. Measured by `scripts/plugin-loop/bench.py` (`docs/AGENTS_PLAN.md`, "Fast enough to +//! watch"). +//! +//! One open per plugin at a time. A rebuild that lands while its plugin is still opening is picked +//! up after the swap, by checking the binaries again. A build the swap cannot take — the old +//! plugin has unsaved documents, or the new one fails to register — leaves the running one in +//! place, as a failed synchronous reload did. +const PluginReloads = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const App = @import("app").App; +const PluginLoader = @import("app").store.Loader; +const Editor = @import("Editor.zig"); + +jobs: std.ArrayListUnmanaged(*Job) = .empty, + +/// Native only: the web links a plugin as a wasm side module (`loadWebPlugin`), with no reload. +const native = builtin.target.cpu.arch != .wasm32; +const Opened = if (native) PluginLoader.Opened else void; + +const Job = struct { + id: []u8, + /// The installed binary; the loaded plugin's own once it is swapped in. + path: []u8, + window: *dvui.Window, + thread: std.Thread = undefined, + started_ns: i96, + /// The worker's, read by the UI thread once `done`. + opened: PluginLoader.LoadError!Opened = error.DylibOpenFailed, + opened_ns: i96 = 0, + done: std.atomic.Value(bool) = .init(false), + /// Another rebuild landed while this one opened: check the binaries again after the swap. + again: bool = false, +}; + +/// Start opening `id`'s rebuilt binary off the UI thread. On the UI thread. +pub fn start(self: *PluginReloads, editor: *Editor, id: []const u8) void { + if (comptime !native) return; + for (self.jobs.items) |job| if (std.mem.eql(u8, job.id, id)) { + job.again = true; + return; + }; + const gpa = editor.app.gpa; + const own_id = gpa.dupe(u8, id) catch return; + const path = App.userPluginPath(gpa, &editor.app, id) catch return gpa.free(own_id); + const job = gpa.create(Job) catch { + gpa.free(own_id); + return gpa.free(path); + }; + job.* = .{ + .id = own_id, + .path = path, + .window = fizzy.entry().window, + .started_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds, + }; + self.jobs.append(gpa, job) catch return freeJob(gpa, job); + job.thread = std.Thread.spawn(.{}, work, .{job}) catch |err| { + dvui.log.warn("plugin watcher: could not start reloading '{s}': {t}", .{ id, err }); + _ = self.jobs.pop(); + return freeJob(gpa, job); + }; +} + +fn work(job: *Job) void { + // The worker's own allocator: `open` only needs a scratch path from it. + job.opened = PluginLoader.open(std.heap.smp_allocator, job.path, job.id); + job.opened_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds; + job.done.store(true, .release); + // Wake the app: the swap happens on its next frame. + dvui.refresh(job.window, @src(), null); +} + +/// Swap in every plugin whose new build has finished opening. Once a frame, on the UI thread. +pub fn frame(self: *PluginReloads, editor: *Editor) void { + if (comptime !native) return; + var i: usize = 0; + while (i < self.jobs.items.len) { + const job = self.jobs.items[i]; + if (!job.done.load(.acquire)) { + i += 1; + continue; + } + job.thread.join(); + _ = self.jobs.orderedRemove(i); + swap(editor, job); + } +} + +fn swap(editor: *Editor, job: *Job) void { + const gpa = editor.app.gpa; + var again = job.again; + defer { + gpa.free(job.id); + gpa.destroy(job); + // A rebuild that landed mid-open: its stamp is newer than what was just loaded. + if (again) editor.reconcileChangedPluginBinaries(); + } + var opened = job.opened catch |err| { + dvui.log.warn("plugin watcher: could not reload rebuilt '{s}' ({s}): {s}", .{ job.id, @errorName(err), App.pluginLoadFailureReason(err) }); + gpa.free(job.path); + // Not again until the binary changes again (as a failed synchronous reload did). + editor.app.restampLoadedPlugin(job.id); + again = false; + return; + }; + const start_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds; + editor.unloadPlugin(job.id, false) catch |err| { + dvui.log.warn("plugin watcher: could not reload rebuilt '{s}' ({s})", .{ job.id, @errorName(err) }); + opened.close(); + gpa.free(job.path); + editor.app.restampLoadedPlugin(job.id); + again = false; + return; + }; + const unloaded_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds; + // Takes `opened` and its path either way. + editor.finishUserPluginLoad(job.id, opened) catch |err| { + dvui.log.warn("plugin watcher: rebuilt '{s}' did not register ({s})", .{ job.id, @errorName(err) }); + return; + }; + const loaded_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds; + dvui.log.info("plugin '{s}': swapped in {d:.1}ms on the UI thread (unload {d:.1}ms, register {d:.1}ms), opened off it in {d:.1}ms", .{ + job.id, + ms(start_ns, loaded_ns), + ms(start_ns, unloaded_ns), + ms(unloaded_ns, loaded_ns), + ms(job.started_ns, job.opened_ns), + }); + dvui.log.info("plugin watcher: reloaded '{s}' from its rebuilt binary", .{job.id}); +} + +/// Wait for every open in flight and drop what it opened. At shutdown, before plugins unload. +pub fn deinit(self: *PluginReloads, gpa: std.mem.Allocator) void { + if (comptime !native) return; + for (self.jobs.items) |job| { + job.thread.join(); + if (job.opened) |o| { + var opened = o; + opened.close(); + } else |_| {} + freeJob(gpa, job); + } + self.jobs.deinit(gpa); +} + +fn freeJob(gpa: std.mem.Allocator, job: *Job) void { + gpa.free(job.id); + gpa.free(job.path); + gpa.destroy(job); +} + +fn ms(from_ns: i96, to_ns: i96) f64 { + return @as(f64, @floatFromInt(to_ns - from_ns)) / std.time.ns_per_ms; +} From c2c516de4710a844cae14e54835d88d878b85c3d Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 9 Oct 2026 12:45:33 -0500 Subject: [PATCH 2/2] ci: measure a plugin's rebuild loop on Linux, per backend and incremental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/plugin-loop.yml` (on demand, and on PRs touching the loop's files; not part of ci-ok) runs scripts/plugin-loop/bench.py on Linux for `plugins/image`: cold, nothing changed and one-line-edit builds in Debug and ReleaseFast with Zig's default backend, LLVM forced and self-hosted forced, and edits under `zig build --watch -fincremental`, written to the job summary. A `zig_version` input re-measures on a newer Zig — 0.17 is expected to carry incremental compilation further. `FIZZY_PLUGIN_USE_LLVM=1|0` (read by sdk/plugin_sdk.zig) forces a plugin build's code generator. The bench gains `--backend`, `--watch N` and `--summary`, reports a combination that cannot build as a row, and defaults to `plugins/image` (examples/ is moving out of the repo, #282). Compiler experiments run here, not on a shared workstation: forcing the self-hosted backend locally, beside other sessions' compiles, took a machine's memory down. Part of #250. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugin-loop.yml | 79 +++++++++++++ scripts/plugin-loop/bench.py | 188 +++++++++++++++++++++++------- sdk/plugin_sdk.zig | 6 + 3 files changed, 229 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/plugin-loop.yml diff --git a/.github/workflows/plugin-loop.yml b/.github/workflows/plugin-loop.yml new file mode 100644 index 000000000..c2ab0a563 --- /dev/null +++ b/.github/workflows/plugin-loop.yml @@ -0,0 +1,79 @@ +name: Plugin rebuild loop + +# How long a plugin takes from an edit saved to a rebuilt binary installed, per code generator and +# with incremental compilation (`scripts/plugin-loop/bench.py`; `docs/AGENTS_PLAN.md`, "Fast enough +# to watch"). A measurement, not a gate: it is not part of `ci-ok`. +# +# On Linux CI rather than a workstation: forcing an experimental backend on a large compile can take +# far more memory than LLVM, and a shared machine has crashed doing it. +# +# Run it by hand with a newer Zig to see what an upgrade buys (Zig 0.17 is expected to extend +# incremental compilation): Actions > Plugin rebuild loop > Run workflow, `zig_version` = 0.17.0 or +# `master`. A plugin that no longer builds on it fails the run, which is worth knowing too. + +on: + workflow_dispatch: + inputs: + zig_version: + description: "Zig to measure with (a version, or master)" + default: "0.16.0" + backends: + description: "Comma-separated: default, llvm, self-hosted" + default: "default,llvm,self-hosted" + watch_edits: + description: "Edits timed under zig build --watch -fincremental (0 to skip)" + default: "3" + pull_request: + paths: + - scripts/plugin-loop/** + - .github/workflows/plugin-loop.yml + - sdk/plugin_sdk.zig + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + ZIG_VERSION: ${{ inputs.zig_version || '0.16.0' }} + +jobs: + measure: + name: Measure (Linux) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: ${{ env.ZIG_VERSION }} + use-cache: false + # Borrow the Linux test job's Zig cache (see ci.yml's "Restore Zig cache") so dependencies + # come unpacked and only the plugin compiles; never saved from here. + - name: Restore Zig cache + uses: actions/cache/restore@v4 + with: + path: .zig-cache + key: zig-${{ env.ZIG_VERSION }}-plugin-loop-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/build.zig.zon') }} + restore-keys: | + zig-${{ env.ZIG_VERSION }}-test-${{ runner.os }}-${{ runner.arch }}- + - name: Pre-create Zig cache tmp/ + run: mkdir -p "$ZIG_GLOBAL_CACHE_DIR/tmp" "$ZIG_LOCAL_CACHE_DIR/tmp" + - name: Fetch the plugin's dependencies (with retries) + working-directory: plugins/image + run: bash "$GITHUB_WORKSPACE/scripts/ci-retry.sh" zig build --fetch + - name: Machine + run: | + { + echo "### Runner" + echo + echo "\`$(nproc)\` cores, \`$(free -g | awk '/Mem:/ {print $2}')\` GB memory, zig \`$(zig version)\`" + echo + } >> "$GITHUB_STEP_SUMMARY" + - name: Measure + run: | + python3 scripts/plugin-loop/bench.py \ + --plugin plugins/image --id image \ + --optimize Debug,ReleaseFast \ + --backend "${{ inputs.backends || 'default,llvm,self-hosted' }}" \ + --watch "${{ inputs.watch_edits || '3' }}" \ + --summary "$GITHUB_STEP_SUMMARY" diff --git a/scripts/plugin-loop/bench.py b/scripts/plugin-loop/bench.py index 51bf405b9..f6f8340c2 100755 --- a/scripts/plugin-loop/bench.py +++ b/scripts/plugin-loop/bench.py @@ -2,23 +2,35 @@ """Time each stage of a plugin's rebuild loop: from an edit saved to the plugin running again. `docs/AGENTS_PLAN.md`, "Fast enough to watch": every stage is measured before it is called fast. -This measures them for one plugin (default: `examples/hello-plugin`, the smallest third-party-shaped -plugin, depending only on `sdk/`): +This measures them for one plugin (default: `plugins/image`, a real plugin in the shape a +third-party one has, depending only on `sdk/`): build `zig build` of the plugin into a scratch profile (`FIZZY_PROFILE`): a cold build (the plugin's own cache empty, the global Zig cache warm, as for a new plugin on a machine that has built fizzy), a rebuild with nothing changed, and a rebuild after a one-line edit — each with Zig's per-step times (compile, install). + watch with `--watch N`: a long-running `zig build --watch -fincremental`, and N edits, each + timed from the save to the rebuilt binary installed. What incremental compilation buys + depends on the Zig version and the backend (`--backend`). reload with `--app `: a sandbox fizzy runs on that profile, and each edit and rebuild is picked up by its plugin watcher. fizzy logs how long after the binary was written it noticed, how long the new binary took to open off the UI thread, and how long the swap - on the UI thread took (unload, register) — `PluginReloads`. + on the UI thread took (unload, register) — `PluginReloads`. Needs a plugin fizzy does + not bundle (fizzy skips a user copy of a bundled one): pass `--plugin` and `--id`. + +`--backend llvm|self-hosted` forces the code generator for the plugin (`FIZZY_PLUGIN_USE_LLVM`, +read by `sdk/plugin_sdk.zig`); `default` leaves it to Zig. Compiler experiments like this run on +Linux CI (`.github/workflows/plugin-loop.yml`), not on a shared workstation: an experimental +backend on a large compile can take far more memory than LLVM. The plugin is copied to `zig-out/plugin-loop/` first, so the repo's copy is never edited, and it installs into a scratch profile, never the real plugins directory. With `--app`, a fizzy window opens for the length of the run: leave it alone, a click on it is a person's input. - scripts/plugin-loop/bench.py [--optimize Debug,ReleaseFast] [--edits 3] [--app zig-out/arm64-macos/fizzy] + scripts/plugin-loop/bench.py [--plugin plugins/image] [--optimize Debug,ReleaseFast] + [--backend default|llvm|self-hosted] [--watch 3] + [--app zig-out/arm64-macos/fizzy --plugin --id ] + [--summary $GITHUB_STEP_SUMMARY] """ import argparse import os @@ -31,10 +43,12 @@ import time ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +EXT = {"darwin": ".dylib", "win32": ".dll"}.get(sys.platform, ".so") def step_times(summary: str) -> dict: - """Zig's `--summary all` per-step durations, by step kind ("compile", "install"), in ms.""" + """Zig's `--summary all` per-step durations, by step kind ("compile", "install"), in ms. + Zig prints a step of a second or more in whole seconds, so these are coarse above 1 s.""" out = {} for line in summary.splitlines(): m = re.search(r"\b(compile|install)\b.*?\bsuccess\s+([\d.]+)(ms|s|us|m)\b", line) @@ -45,16 +59,30 @@ def step_times(summary: str) -> dict: return out -def build(plugin_dir: str, optimize: str, profile: str) -> tuple[float, dict]: +def env_for(profile: str, backend: str) -> dict: env = dict(os.environ, FIZZY_PROFILE=profile) + if backend == "llvm": + env["FIZZY_PLUGIN_USE_LLVM"] = "1" + elif backend == "self-hosted": + env["FIZZY_PLUGIN_USE_LLVM"] = "0" + return env + + +class BuildFailed(Exception): + pass + + +def build(plugin_dir: str, optimize: str, profile: str, backend: str) -> tuple: start = time.monotonic() proc = subprocess.run( ["zig", "build", f"-Doptimize={optimize}", "--summary", "all"], - cwd=plugin_dir, env=env, capture_output=True, text=True, + cwd=plugin_dir, env=env_for(profile, backend), capture_output=True, text=True, ) wall = (time.monotonic() - start) * 1000 if proc.returncode != 0: - sys.exit(f"build failed:\n{proc.stderr}") + print(proc.stderr[-4000:], file=sys.stderr) + errors = [l.strip() for l in proc.stderr.splitlines() if "error:" in l] + raise BuildFailed(errors[0] if errors else f"exit {proc.returncode}") return wall, step_times(proc.stderr) @@ -64,17 +92,16 @@ def edit(plugin_dir: str, n: int) -> None: f.write(f"// plugin-loop bench edit {n}\n") -def copy_plugin(src: str, name: str) -> str: - dst = os.path.join(ROOT, "zig-out", "plugin-loop", name) +def copy_plugin(src: str) -> str: + dst = os.path.join(ROOT, "zig-out", "plugin-loop", os.path.basename(src)) shutil.rmtree(dst, ignore_errors=True) shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".zig-cache", "zig-out", "zig-pkg"), dirs_exist_ok=True) - # The copy sits one level deeper than `examples/`: its path to the SDK does too. + # The copy sits one level deeper than `/`: its path to the SDK does too. zon = os.path.join(dst, "build.zig.zon") with open(zon) as f: text = f.read() - text = text.replace('"../../sdk"', '"../../../sdk"') with open(zon, "w") as f: - f.write(text) + f.write(text.replace('"../../sdk"', '"../../../sdk"')) return dst @@ -82,43 +109,98 @@ def fmt(ms: float) -> str: return f"{ms / 1000:.2f} s" if ms >= 1000 else f"{ms:.0f} ms" -def bench_builds(plugin_dir: str, optimize: str, profile: str) -> None: +class Report: + """What the run measured, printed as it goes and kept for `--summary`.""" + + def __init__(self) -> None: + self.rows = [] + + def add(self, label: str, value: str, detail: str = "") -> None: + print(f" {label:<44} {value:>9} {detail}", flush=True) + self.rows.append((label, value, detail)) + + def markdown(self, title: str) -> str: + lines = [f"### {title}", "", "| | | |", "|---|---|---|"] + lines += [f"| {a} | {b} | {c} |" for a, b, c in self.rows] + return "\n".join(lines) + "\n\n" + + +def bench_builds(report: Report, plugin_dir: str, optimize: str, profile: str, backend: str) -> None: shutil.rmtree(os.path.join(plugin_dir, ".zig-cache"), ignore_errors=True) - rows = [] - rows.append(("cold", *build(plugin_dir, optimize, profile))) - rows.append(("nothing changed", *build(plugin_dir, optimize, profile))) - edit(plugin_dir, 0) - rows.append(("one-line edit", *build(plugin_dir, optimize, profile))) - print(f"\n{optimize}: zig build of {os.path.basename(plugin_dir)}") - for name, wall, steps in rows: - detail = ", ".join(f"{k} {fmt(v)}" for k, v in steps.items()) - print(f" {name:<16} {fmt(wall):>9} {detail}") - - -def bench_reload(plugin_dir: str, optimize: str, profile: str, app: str, edits: int, plugin_id: str) -> None: + for name, change in (("cold", False), ("nothing changed", False), ("one-line edit", True)): + if change: + edit(plugin_dir, 0) + wall, steps = build(plugin_dir, optimize, profile, backend) + report.add(f"{optimize}, {backend}: {name}", fmt(wall), ", ".join(f"{k} {fmt(v)}" for k, v in steps.items())) + + +def installed(profile: str, plugin_id: str) -> str: + return os.path.join(profile, "plugins", plugin_id, plugin_id + EXT) + + +def wait_mtime_change(path: str, before: int, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if os.stat(path).st_mtime_ns != before: + return True + except FileNotFoundError: + pass + time.sleep(0.01) + return False + + +def bench_watch(report: Report, plugin_dir: str, optimize: str, profile: str, backend: str, edits: int, plugin_id: str) -> None: + target = installed(profile, plugin_id) + build(plugin_dir, optimize, profile, backend) # installed once, so each edit is a change to it + log = open(os.path.join(profile, "watch.log"), "w") + proc = subprocess.Popen( + ["zig", "build", "--watch", "-fincremental", f"-Doptimize={optimize}"], + cwd=plugin_dir, env=env_for(profile, backend), stdout=log, stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + # The first pass under --watch builds what the incremental cache does not have yet. + wait_mtime_change(target, os.stat(target).st_mtime_ns, 300) + time.sleep(1) + for n in range(1, edits + 1): + before = os.stat(target).st_mtime_ns + start = time.monotonic() + edit(plugin_dir, 100 + n) + if not wait_mtime_change(target, before, 300): + report.add(f"{optimize}, {backend}: watch edit {n}", "timeout", "no rebuild within 300 s") + continue + report.add(f"{optimize}, {backend}: --watch -fincremental edit {n}", fmt((time.monotonic() - start) * 1000), "save to installed") + time.sleep(0.5) + finally: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait() + log.close() + + +def bench_reload(report: Report, plugin_dir: str, optimize: str, profile: str, app: str, edits: int, plugin_id: str) -> None: with open(os.path.join(profile, "settings.zon"), "w") as f: f.write(".{ .plugins = .{ .%s = .{ .enabled = true } } }\n" % plugin_id) - build(plugin_dir, optimize, profile) + build(plugin_dir, optimize, profile, "default") log_path = os.path.join(profile, "fizzy.log") log = open(log_path, "w") proc = subprocess.Popen([app, "--profile", profile], stdout=log, stderr=subprocess.STDOUT) try: if not wait_for(log_path, rf"user plugin '{plugin_id}' loaded", 30): - sys.exit(f"fizzy never loaded '{plugin_id}' — see {log_path}") - print(f"\n{optimize}: edit, rebuild and reload in a running fizzy ({os.path.basename(app)})") + sys.exit(f"fizzy never loaded '{plugin_id}' — see {log_path} (a bundled plugin is skipped)") for n in range(1, edits + 1): seen = count(log_path, "swapped in") - edit(plugin_dir, n) + edit(plugin_dir, 200 + n) start = time.monotonic() - wall, steps = build(plugin_dir, optimize, profile) + wall, steps = build(plugin_dir, optimize, profile, "default") if not wait_for(log_path, r"swapped in", 30, after=seen): sys.exit(f"fizzy never reloaded the rebuild — see {log_path}") total = (time.monotonic() - start) * 1000 noticed = last(log_path, r"noticed ([\d.]+)ms after it was written") swap = last(log_path, r"swapped in ([\d.]+)ms on the UI thread \(unload ([\d.]+)ms, register ([\d.]+)ms\), opened off it in ([\d.]+)ms") - print(f" edit {n}: build {fmt(wall)} (compile {fmt(steps.get('compile', 0))}), " - f"noticed {noticed[0]} ms after written, opened off the UI thread in {swap[3]} ms, " - f"swap on it {swap[0]} ms (unload {swap[1]}, register {swap[2]}) — {fmt(total)} from the edit to running") + report.add(f"{optimize}: reload {n}", fmt(total), + f"build {fmt(wall)}, noticed {noticed[0]} ms after written, opened off the UI thread " + f"{swap[3]} ms, swap on it {swap[0]} ms (unload {swap[1]}, register {swap[2]})") finally: proc.send_signal(signal.SIGKILL) proc.wait() @@ -150,23 +232,41 @@ def last(path: str, pattern: str) -> tuple: def main() -> None: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--plugin", default="examples/hello-plugin", help="plugin directory, relative to the repo") - p.add_argument("--id", default="hello", help="the plugin's id") + p.add_argument("--plugin", default="plugins/image", help="plugin directory, relative to the repo") + p.add_argument("--id", default="image", help="the plugin's id") p.add_argument("--optimize", default="Debug,ReleaseFast", help="comma-separated optimize modes") + p.add_argument("--backend", default="default", help="comma-separated: default, llvm, self-hosted") + p.add_argument("--watch", type=int, default=0, help="edits to time under `zig build --watch -fincremental`") p.add_argument("--edits", type=int, default=3, help="edit/rebuild/reload cycles with --app") p.add_argument("--app", help="a fizzy executable, for the reload stage") p.add_argument("--app-optimize", default="Debug", help="the mode --app was built in: a plugin loads only into a build of its own mode") + p.add_argument("--summary", help="append the results as a markdown table to this file (CI: $GITHUB_STEP_SUMMARY)") args = p.parse_args() + report = Report() + zig = subprocess.run(["zig", "version"], capture_output=True, text=True).stdout.strip() + print(f"zig {zig}, {sys.platform}, plugin {args.plugin}", flush=True) for optimize in args.optimize.split(","): - plugin_dir = copy_plugin(os.path.join(ROOT, args.plugin), os.path.basename(args.plugin)) - profile = tempfile.mkdtemp(prefix="fizzy-plugin-loop-") - try: - bench_builds(plugin_dir, optimize, profile) - if args.app and optimize == args.app_optimize: - bench_reload(plugin_dir, optimize, profile, os.path.abspath(args.app), args.edits, args.id) - finally: - shutil.rmtree(profile, ignore_errors=True) + for backend in args.backend.split(","): + plugin_dir = copy_plugin(os.path.join(ROOT, args.plugin)) + profile = tempfile.mkdtemp(prefix="fizzy-plugin-loop-") + try: + try: + bench_builds(report, plugin_dir, optimize, profile, backend) + except BuildFailed as err: + # A backend that cannot build this (self-hosted at ReleaseFast, say) is a row, + # not the end of the run. + report.add(f"{optimize}, {backend}", "failed", str(err)[:200].replace("|", "/")) + continue + if args.watch: + bench_watch(report, plugin_dir, optimize, profile, backend, args.watch, args.id) + if args.app and optimize == args.app_optimize and backend == "default": + bench_reload(report, plugin_dir, optimize, profile, os.path.abspath(args.app), args.edits, args.id) + finally: + shutil.rmtree(profile, ignore_errors=True) + if args.summary: + with open(args.summary, "a") as f: + f.write(report.markdown(f"Plugin rebuild loop: `{args.plugin}`, zig {zig}, {sys.platform}")) if __name__ == "__main__": diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index e42942530..d34ac712f 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -551,6 +551,12 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact { }); lib.linker_allow_shlib_undefined = true; lib.root_module.export_symbol_names = &dylib_exports; + // `FIZZY_PLUGIN_USE_LLVM=1` / `=0` forces the code generator for a plugin build; unset, Zig + // chooses its default for the target. For measuring the rebuild + // loop per backend (`scripts/plugin-loop/bench.py --backend`, `.github/workflows/plugin-loop.yml`): + // an environment variable rather than a `-D` option, because fizzy's own build creates several + // plugins on one builder, and an option may be declared only once. + if (b.graph.environ_map.get("FIZZY_PLUGIN_USE_LLVM")) |v| lib.use_llvm = !std.mem.eql(u8, v, "0"); if (is_wasm) { // A wasm *side module* (Emscripten's term): position-independent, no entry, no // threads, loaded into the web host's memory and function table at runtime — the