initial commit. working prototype.

This commit is contained in:
2025-07-20 11:52:24 -04:00
commit 2289490ca8
7 changed files with 543 additions and 0 deletions

24
src/bar.zig Normal file
View File

@@ -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())});
}

166
src/core.zig Normal file
View File

@@ -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,
};
}

25
src/foo.zig Normal file
View File

@@ -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())});
}

190
src/main.zig Normal file
View File

@@ -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, .{});
}