commit 2289490ca8a0a8bcc5a87fbbe3438cd977238d2f Author: David Allemang Date: Sun Jul 20 11:52:24 2025 -0400 initial commit. working prototype. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..104cddc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.idea +/.envrc + +/zig-out/ +/.zig-cache/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..0df8a7d --- /dev/null +++ b/build.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +pub fn installModule(b: *std.Build, plug: *std.Build.Step.Compile) void { + std.debug.assert(plug.isDynamicLibrary()); + const install = b.addInstallArtifact(plug, .{ + .dest_dir = .{ .override = .{ .custom = "mods" } }, + }); + b.getInstallStep().dependOn(&install.step); +} + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exedriver = b.addExecutable(.{ + .name = "driver", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + b.installArtifact(exedriver); + + const libfoo = b.addSharedLibrary(.{ + .name = "foo", + .root_source_file = b.path("src/foo.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + installModule(b, libfoo); + + const libbar = b.addSharedLibrary(.{ + .name = "bar", + .root_source_file = b.path("src/bar.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + installModule(b, libbar); + + const run = b.addRunArtifact(exedriver); + run.step.dependOn(b.getInstallStep()); + run.setEnvironmentVariable("MODS_DIR", b.getInstallPath(.{ .custom = "mods" }, "")); + + b.step("run", "").dependOn(&run.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..6027e5e --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,86 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .zig_hotswap, + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0xa7823a599d65a583, // Changing this has security and trust implications. + + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.14.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save ` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. If the contents of a URL change this will result in a hash mismatch + // // which will prevent zig from using it. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + // + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/src/bar.zig b/src/bar.zig new file mode 100644 index 0000000..ca32e3b --- /dev/null +++ b/src/bar.zig @@ -0,0 +1,24 @@ +const std = @import("std"); +const core = @import("core.zig"); + +const Self = @This(); + +export const MODULE = core.module(Self); + +pub fn startup(_: std.mem.Allocator) !Self { + std.log.debug("!! startup {s} !!", .{@typeName(@This())}); + return .{}; +} + +pub fn unload(_: *Self, _: std.mem.Allocator) ![]u8 { + std.log.debug("!! unload {s} !!", .{@typeName(@This())}); + return &.{}; +} + +pub fn reload(_: *Self, _: std.mem.Allocator, _: []u8) !void { + std.log.debug("!! reload {s} !!", .{@typeName(@This())}); +} + +pub fn shutdown(_: *Self, _: std.mem.Allocator) void { + std.log.debug("!! shutdown {s} !!", .{@typeName(@This())}); +} diff --git a/src/core.zig b/src/core.zig new file mode 100644 index 0000000..06dc8a0 --- /dev/null +++ b/src/core.zig @@ -0,0 +1,166 @@ +const std = @import("std"); + +// Notes: +// +// "init" is an overloaded term. Try not to use it. Instead, have hooks: +// - startup / shutdown. executed once each per program lifecycle. +// - load / unload. executed once per module load cycle. +// +// So the flow is: +// - startup. prepares long-lived state. +// - load. deserializes and prepares short-lived state. +// - unload. serializes and releases short-lived state. +// - shutdown. releases long-lived state. +// +// So when the hotplug system reloads the module, it performs an unload/load cycle. +// Normal operation without any reloads includes all four stages. +// +// Some considerations: +// - a module should be able to indicate specific failure: +// - startup (preparing state) +// - load (deserialization or preparing state) +// - unload (serialization) +// - a module should be able to indicate it cannot be reloaded. +// - serialization data might come from disk, or from a reload, or be missing. +// +// On reloading and rollback: +// - when loading a module, copy the dynlib to a versioned temporary file, and load that temporary file. +// - procedure for reloading a module: +// - current old version unload (and serialize) +// - attempt new version load (and deserialize) +// - if success: release old version and delete lib +// - if failure: +// - release new version and delete lib. +// - attempt old version load (and deserialize) (catch fatal error) +// - Configure signal handlers to always clean up the temporary files on errors like segfault? +// - temp files needs to gracefully handle multiple instances of the app running at once with shared hotloading. +// +// Things should be done in a thread-safe manner. Signal to a module that it should unload gracefully, and the +// module signal when it can safely be unloaded. + +pub fn ModuleType(Self: type) type { + return extern struct { + pub const Startup = fn (alloc: std.mem.Allocator) anyerror!Self; + pub const CStartup = fn (alloc: *const std.mem.Allocator) callconv(.c) ?*anyopaque; + + pub const Unload = fn (self: *Self, alloc: std.mem.Allocator) anyerror![]u8; + pub const CUnload = fn (self: *Self, alloc: *const std.mem.Allocator, ptr: *[*]u8, len: *usize) callconv(.c) bool; + + pub const Reload = fn (self: *Self, alloc: std.mem.Allocator, data: []u8) anyerror!void; + pub const CReload = fn (self: *Self, alloc: *const std.mem.Allocator, ptr: [*]u8, len: usize) callconv(.c) bool; + + pub const Shutdown = fn (self: *Self, alloc: std.mem.Allocator) void; + pub const CShutdown = fn (self: *Self, alloc: *const std.mem.Allocator) callconv(.c) void; + + cstartup: *const CStartup, + creload: *const CReload, + cunload: *const CUnload, + cshutdown: *const CShutdown, + + pub fn startup(mod: @This(), alloc: std.mem.Allocator) !*Self { + return mod.cstartup(&alloc) orelse error.ModuleInitFailed; + } + + pub fn unload(mod: @This(), self: *Self, alloc: std.mem.Allocator) ![]u8 { + var data: []u8 = undefined; + if (mod.cunload(self, &alloc, &data.ptr, &data.len)) { + return data; + } else { + return error.ModuleUnloadFailed; + } + } + + pub fn reload(mod: @This(), self: *Self, alloc: std.mem.Allocator, data: []u8) !void { + if (mod.creload(self, &alloc, data.ptr, data.len)) {} else { + return error.ModuleReloadFailed; + } + } + + pub fn shutdown(mod: @This(), self: *Self, alloc: std.mem.Allocator) void { + mod.cshutdown(self, &alloc); + } + }; +} + +pub fn module(Self: type) ModuleType(Self) { + const Module = ModuleType(Self); + + const impl = struct { + pub const startup: Module.Startup = Self.startup; + + pub fn cstartup( + alloc: *const std.mem.Allocator, + ) callconv(.c) ?*anyopaque { + const self: *Self = alloc.create(Self) catch |err| { + std.log.err( + "Module {s} startup failed: {!}", + .{ @typeName(Self), err }, + ); + return null; + }; + self.* = startup(alloc.*) catch |err| { + alloc.destroy(self); + std.log.err( + "Module {s} startup failed: {!}", + .{ @typeName(Self), err }, + ); + return null; + }; + return self; + } + + pub const unload: Module.Unload = Self.unload; + + pub fn cunload( + self: *Self, + alloc: *const std.mem.Allocator, + ptr: *[*]u8, + len: *usize, + ) callconv(.c) bool { + const data = unload(self, alloc.*) catch |err| { + std.log.err( + "Module {s} unload failed: {!}", + .{ @typeName(Self), err }, + ); + return false; + }; + ptr.* = data.ptr; + len.* = data.len; + return true; + } + + pub const reload: Module.Reload = Self.reload; + + pub fn creload( + self: *Self, + alloc: *const std.mem.Allocator, + ptr: [*]u8, + len: usize, + ) callconv(.c) bool { + reload(self, alloc.*, ptr[0..len]) catch |err| { + std.log.err( + "Module {s} reload failed: {!}", + .{ @typeName(Self), err }, + ); + return false; + }; + return true; + } + + pub const shutdown: Module.Shutdown = Self.shutdown; + + pub fn cshutdown( + self: *Self, + alloc: *const std.mem.Allocator, + ) callconv(.c) void { + shutdown(self, alloc.*); + } + }; + + return .{ + .cstartup = &impl.cstartup, + .creload = &impl.creload, + .cunload = &impl.cunload, + .cshutdown = &impl.cshutdown, + }; +} diff --git a/src/foo.zig b/src/foo.zig new file mode 100644 index 0000000..1dbd818 --- /dev/null +++ b/src/foo.zig @@ -0,0 +1,25 @@ +const std = @import("std"); +const core = @import("core.zig"); + +const Self = @This(); + +export const MODULE = core.module(Self); + +pub fn startup(_: std.mem.Allocator) !Self { + std.log.debug("!! startup {s} !!", .{@typeName(@This())}); + return .{}; +} + +pub fn unload(_: *Self, _: std.mem.Allocator) ![]u8 { + std.log.debug("!! unload {s} !!", .{@typeName(@This())}); + return &.{}; +} + +pub fn reload(_: *Self, _: std.mem.Allocator, _: []u8) !void { + std.log.debug("!! reload {s} !!", .{@typeName(@This())}); + std.log.debug("!! NEW CONTENT !!", .{}); +} + +pub fn shutdown(_: *Self, _: std.mem.Allocator) void { + std.log.debug("!! shutdown {s} !!", .{@typeName(@This())}); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..29af81c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const linux = std.os.linux; +const core = @import("core.zig"); + +const ModLib = struct { + path: []const u8, + lib: std.DynLib, + mod: *core.ModuleType(anyopaque), + + fn open(realpath: []const u8, version: u32, alloc: std.mem.Allocator) !ModLib { + const basename = std.fs.path.basename(realpath); + const name = try std.fmt.allocPrint( + alloc, + ".{s}.{d}", + .{ basename, version }, + ); + defer alloc.free(name); + + const path = if (std.fs.path.dirname(realpath)) |dirname| + try std.fs.path.join(alloc, &.{ dirname, name }) + else + try alloc.dupe(u8, name); + errdefer alloc.free(path); + + try std.fs.copyFileAbsolute(realpath, path, .{}); + + var lib = try std.DynLib.open(path); + errdefer lib.close(); + + const mod = lib.lookup(*core.ModuleType(anyopaque), "MODULE") orelse + return error.MissingModuleDefinition; + + return .{ + .path = path, + .lib = lib, + .mod = mod, + }; + } + + fn close(self: *ModLib, alloc: std.mem.Allocator) void { + self.lib.close(); + // std.fs.deleteFileAbsolute(self.path) catch std.log.warn("Failed to delete {s}", .{self.path}); + alloc.free(self.path); + } +}; + +const DynamicModule = struct { + gpa: std.heap.GeneralPurposeAllocator(.{}), + realpath: []const u8, + curr: ModLib, + state: *anyopaque, + next: u32, + + pub fn init(path: []const u8, alloc: std.mem.Allocator) !DynamicModule { + const realpath = try std.fs.cwd().realpathAlloc(alloc, path); + errdefer alloc.free(realpath); + + var gpa = std.heap.GeneralPurposeAllocator(.{}).init; + errdefer if (gpa.deinit() == .leak) { + std.log.warn("Module {s} leaked.", .{realpath}); + }; + + var self: DynamicModule = .{ + .gpa = gpa, + .realpath = realpath, + .curr = undefined, + .state = undefined, + .next = 0, + }; + + self.curr = try ModLib.open(realpath, self.next, alloc); + errdefer self.curr.close(alloc); + + self.state = try self.curr.mod.startup(self.gpa.allocator()); + errdefer self.curr.mod.shutdown(self.state, self.gpa.allocator()); + + // todo deserialize from disk. + try self.curr.mod.reload(self.state, self.gpa.allocator(), &.{}); + + self.next += 1; + + return self; + } + + pub fn reload(self: *@This(), alloc: std.mem.Allocator) !void { + var next = try ModLib.open(self.realpath, self.next, alloc); + errdefer next.close(alloc); + + const data = try self.curr.mod.unload(self.state, self.gpa.allocator()); + errdefer self.curr.mod.reload(self.state, self.gpa.allocator(), data) catch + std.debug.panic("Failed to rollback to {s}", .{self.curr.path}); + + try next.mod.reload(self.state, self.gpa.allocator(), data); + + self.curr.close(alloc); + self.next += 1; + self.curr = next; + } + + pub fn deinit(self: *DynamicModule, alloc: std.mem.Allocator) void { + const data = self.curr.mod.unload(self.state, self.gpa.allocator()) catch + std.debug.panic("Failed to unload {s}", .{self.curr.path}); + + _ = data; // todo serialize to disk. + + self.curr.mod.shutdown(self.state, self.gpa.allocator()); + + self.curr.close(alloc); + + if (self.gpa.deinit() == .leak) { + std.log.warn("Module {s} leaked.", .{self.realpath}); + } + + alloc.free(self.realpath); + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}).init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const self = try std.fs.selfExeDirPathAlloc(alloc); + defer alloc.free(self); + std.log.debug("running from '{s}'", .{self}); + + var env = try std.process.getEnvMap(alloc); + defer env.deinit(); + + const mods_path = if (env.get("MODS_DIR")) |envvar| blk: { + break :blk try alloc.dupe(u8, envvar); + } else blk: { + const dir = try std.fs.selfExeDirPathAlloc(alloc); + defer alloc.free(dir); + break :blk try std.fs.path.resolve(alloc, &.{ dir, "..", "mods" }); + }; + defer alloc.free(mods_path); + + var mods: std.ArrayListUnmanaged(DynamicModule) = .{}; + defer { + for (mods.items) |*mod| { + mod.deinit(alloc); + } + mods.deinit(alloc); + } + + std.log.debug("loading mods in '{s}'", .{mods_path}); + { + var dir = try std.fs.openDirAbsolute(mods_path, .{ .iterate = true }); + defer dir.close(); + + // const fd = linux.inotify_init1(linux.IN.NONBLOCK); + // defer linux.close(fd); + // var fds = [_]linux.pollfd{ + // .{ .fd = fd, .events = linux.IN.ALL_EVENTS, .revents = undefined }, + // }; + // const n = linux.poll(&fds, fds.len, 0); + + var it = dir.iterate(); + while (try it.next()) |entry| { + const mod_path = try std.fs.path.resolve(alloc, &.{ mods_path, entry.name }); + defer alloc.free(mod_path); + + if (std.mem.startsWith(u8, std.fs.path.basename(mod_path), ".")) + continue; + + var mod = try DynamicModule.init(mod_path, alloc); + errdefer mod.deinit(alloc); + try mods.append(alloc, mod); + } + } + + std.log.debug("-" ** 80, .{}); + for (0..10) |x| { + std.log.debug("{d}...", .{x}); + std.time.sleep(std.time.ns_per_s); + } + std.log.debug("reloading.", .{}); + std.time.sleep(std.time.ns_per_s); + + for (mods.items) |*mod| { + if (mod.reload(alloc)) |_| { + std.log.debug("loaded {s}", .{mod.curr.path}); + } else |_| { + std.log.debug("rolled back to {s}", .{mod.curr.path}); + } + } + + std.log.debug("-" ** 80, .{}); +}