From 128076cf501889f7508e8a49a8abe9a78a7f21ba Mon Sep 17 00:00:00 2001 From: apotema Date: Sun, 28 Jun 2026 14:17:43 -0300 Subject: [PATCH 1/3] =?UTF-8?q?refactor(backends):=20centralize=20name?= =?UTF-8?q?=E2=86=92package=20layout=20in=20backend=5Fregistry=20(#386=20P?= =?UTF-8?q?hase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce src/backend_registry.zig — a string-keyed registry that derives a backend's package facts (subpath backends/{name}, zon_name labelle_{name}, link_name labelle-{name}) from a plain name string instead of the closed config.Backend enum tag. This is the pluggability seam: lookup() resolves a name that has NO enum tag, so a future resolver can hand the name layer a third-party backend name. Add config.ProjectConfig.backendName() as the seam future code reads instead of @tagName(cfg.backend). The enum stays the backward-compat shorthand; parsing arbitrary names is the explicit follow-up. Route the ~8 name-derivation sites through the registry / backendName(): deps_linker (backend dep entry), build_files (build.zig.zon backend dep path), root.zig (backend template subpath + target dir name), main.zig (target dir name), gui_resolve.zig (3 diagnostics). Behavioral switch(cfg.backend) sites (codegen selection, gamepad sub-package staging) are intentionally left alone — those are the manifest splice's job, not the name layer. Tests: pluggability (fictional backend resolves), drift guard (builtin_names <-> Backend tags agree both ways), and a per-built-in inline-convention match. Byte-identical verified: bgfx-desktop + raylib-desktop build.zig / build.zig.zon / game.zig + target dir names unchanged before/after. --- src/backend_registry.zig | 154 +++++++++++++++++++++++++++++++++++++++ src/build_files.zig | 9 ++- src/config.zig | 14 ++++ src/deps_linker.zig | 14 ++-- src/gui_resolve.zig | 6 +- src/main.zig | 2 +- src/root.zig | 22 +++--- 7 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 src/backend_registry.zig diff --git a/src/backend_registry.zig b/src/backend_registry.zig new file mode 100644 index 00000000..76bb93f0 --- /dev/null +++ b/src/backend_registry.zig @@ -0,0 +1,154 @@ +//! backend_registry — the name→package-layout seam for the pluggable-backends +//! epic (#386, Phase 5). +//! +//! Every backend the assembler stages follows ONE uniform package-naming +//! convention, derived purely from its canonical name: +//! +//! package dir : `backends/{name}` +//! zon dep name: `labelle_{name}` +//! link name : `labelle-{name}` +//! +//! Before this module, ~8 splice/codegen sites re-derived these facts inline +//! from `@tagName(cfg.backend)` + `bufPrint`/`allocPrint`, each re-implementing +//! the convention and — crucially — keyed off a CLOSED `config.Backend` enum. +//! A third-party backend can't be an enum tag, so it could never resolve. +//! +//! This registry centralizes the convention in ONE place and keys it by a +//! plain string (`lookup(allocator, name)`), so a name that is NOT a built-in +//! enum tag still resolves to a valid `BackendInfo`. That string-keying is the +//! pluggability point — the seam that a future resolver (which parses an +//! arbitrary `.backend` name, the explicit follow-up) plugs into. +//! +//! NOTE: this is the NAME layer only. Selecting backend-specific *codegen* +//! (the behavioral `switch (cfg.backend)` sites in build_files.zig / +//! deps_linker.zig) is the manifest splice's job and is intentionally NOT +//! handled here. + +const std = @import("std"); +const config = @import("config.zig"); + +/// Package-layout facts for a single backend, derived from its canonical name. +/// The `subpath` / `zon_name` / `link_name` fields are allocator-owned (built +/// by `lookup`); free them with `free`. +pub const BackendInfo = struct { + /// Canonical backend name, e.g. "bgfx". Borrowed — points at the caller's + /// input string, NOT allocator-owned (so `free` leaves it alone). + name: []const u8, + /// Package directory under the staged assembler cache: `backends/{name}`. + /// Allocator-owned. + subpath: []const u8, + /// ZON dependency identifier: `labelle_{name}`. Allocator-owned. + zon_name: []const u8, + /// build.zig link/module name: `labelle-{name}`. Allocator-owned. + link_name: []const u8, +}; + +/// Resolve the package-layout facts for ANY backend name string. +/// +/// Works for names that are NOT `config.Backend` enum tags — that is the whole +/// point: the convention is uniform string derivation, so the registry resolves +/// a plugin backend name exactly the way it resolves a built-in one. The +/// returned `BackendInfo`'s `subpath` / `zon_name` / `link_name` are +/// allocator-owned; free them with `free`. `name` borrows the caller's slice. +pub fn lookup(allocator: std.mem.Allocator, name: []const u8) !BackendInfo { + return .{ + .name = name, + .subpath = try std.fmt.allocPrint(allocator, "backends/{s}", .{name}), + .zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{name}), + .link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{name}), + }; +} + +/// Free the allocator-owned fields of an `info` returned by `lookup`. +/// (`name` is borrowed and is left untouched.) +pub fn free(allocator: std.mem.Allocator, info: BackendInfo) void { + allocator.free(info.subpath); + allocator.free(info.zon_name); + allocator.free(info.link_name); +} + +/// The built-in backend names, seeded directly from `config.Backend`'s tags so +/// the registry and the enum cannot silently drift: adding an enum variant +/// adds a name here automatically, and the drift-guard test cross-checks the +/// two sets in both directions. +pub const builtin_names = blk: { + const fields = @typeInfo(config.Backend).@"enum".fields; + var names: [fields.len][]const u8 = undefined; + for (fields, 0..) |f, i| names[i] = f.name; + const frozen = names; + break :blk frozen; +}; + +/// True if `name` is one of the built-in backend names. +pub fn isBuiltin(name: []const u8) bool { + for (builtin_names) |b| { + if (std.mem.eql(u8, b, name)) return true; + } + return false; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "lookup derives the package convention for a built-in name" { + const alloc = std.testing.allocator; + const info = try lookup(alloc, "bgfx"); + defer free(alloc, info); + + try std.testing.expectEqualStrings("bgfx", info.name); + try std.testing.expectEqualStrings("backends/bgfx", info.subpath); + try std.testing.expectEqualStrings("labelle_bgfx", info.zon_name); + try std.testing.expectEqualStrings("labelle-bgfx", info.link_name); +} + +test "pluggability: lookup resolves a name with NO enum tag" { + const alloc = std.testing.allocator; + // "fictional" is not a config.Backend tag — this is the seam working. + try std.testing.expect(!isBuiltin("fictional")); + + const info = try lookup(alloc, "fictional"); + defer free(alloc, info); + + try std.testing.expectEqualStrings("fictional", info.name); + try std.testing.expectEqualStrings("backends/fictional", info.subpath); + try std.testing.expectEqualStrings("labelle_fictional", info.zon_name); + try std.testing.expectEqualStrings("labelle-fictional", info.link_name); +} + +test "drift guard: builtin_names and config.Backend tags agree both ways" { + // Every enum tag must be a builtin name… + inline for (@typeInfo(config.Backend).@"enum".fields) |f| { + try std.testing.expect(isBuiltin(f.name)); + } + // …and every builtin name must be an enum tag. + for (builtin_names) |name| { + var found = false; + inline for (@typeInfo(config.Backend).@"enum".fields) |f| { + if (std.mem.eql(u8, f.name, name)) found = true; + } + try std.testing.expect(found); + } + // And the counts match (catches a stray addition on either side). + try std.testing.expectEqual( + @typeInfo(config.Backend).@"enum".fields.len, + builtin_names.len, + ); +} + +test "lookup matches the inline convention for all 6 built-ins" { + const alloc = std.testing.allocator; + for (builtin_names) |name| { + const info = try lookup(alloc, name); + defer free(alloc, info); + + const subpath = try std.fmt.allocPrint(alloc, "backends/{s}", .{name}); + defer alloc.free(subpath); + const zon_name = try std.fmt.allocPrint(alloc, "labelle_{s}", .{name}); + defer alloc.free(zon_name); + const link_name = try std.fmt.allocPrint(alloc, "labelle-{s}", .{name}); + defer alloc.free(link_name); + + try std.testing.expectEqualStrings(subpath, info.subpath); + try std.testing.expectEqualStrings(zon_name, info.zon_name); + try std.testing.expectEqualStrings(link_name, info.link_name); + } +} diff --git a/src/build_files.zig b/src/build_files.zig index 885b7105..47ee6dd8 100644 --- a/src/build_files.zig +++ b/src/build_files.zig @@ -3,6 +3,7 @@ const std = @import("std"); const tpl = @import("template.zig"); const config = @import("config.zig"); const cache = @import("cache.zig"); +const backend_registry = @import("backend_registry.zig"); const scan = @import("codegen/scan.zig"); const manifest_splice = @import("codegen/manifest_splice.zig"); pub const deps_linker = @import("deps_linker.zig"); @@ -772,12 +773,12 @@ fn generateZonPathsFallback(allocator: std.mem.Allocator, cfg: ProjectConfig, ta } { - const bn = @tagName(cfg.backend); + const bn = cfg.backendName(); var sb: [64]u8 = undefined; const section = std.fmt.bufPrint(&sb, "dep_{s}_path", .{bn}) catch unreachable; - var spb: [128]u8 = undefined; - const sp = std.fmt.bufPrint(&spb, "backends/{s}", .{bn}) catch unreachable; - const bp_abs = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, sp); + const backend_info = try backend_registry.lookup(allocator, bn); + defer backend_registry.free(allocator, backend_info); + const bp_abs = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath); defer allocator.free(bp_abs); const bp = try relativePath(allocator, abs_target, bp_abs); defer allocator.free(bp); diff --git a/src/config.zig b/src/config.zig index 94ac8b9c..5d1a2dde 100644 --- a/src/config.zig +++ b/src/config.zig @@ -582,6 +582,20 @@ pub const ProjectConfig = struct { return self.resolved_gui != null; } + /// The canonical backend NAME as a string (e.g. "bgfx"). + /// + /// This is the pluggable-backends seam (epic #386, Phase 5): name-layer + /// code reads `backendName()` instead of `@tagName(self.backend)` directly, + /// so the package-layout conventions (see `backend_registry`) are derived + /// from a string rather than a closed enum tag. Today this is just the + /// enum tag — the `Backend` enum remains the backward-compat shorthand and + /// `.backend` still parses as an enum value — but routing through this + /// method is what lets a future resolver hand the name layer a backend name + /// that has no enum tag (the explicit follow-up). + pub fn backendName(self: ProjectConfig) []const u8 { + return @tagName(self.backend); + } + /// The unset-`.y_axis` build guard (RFC-Y-AXIS-CONVENTION Migration §, /// epic labelle-engine#640). During the transition release an *absent* /// `.y_axis` is a hard error naming BOTH choices, so no existing game diff --git a/src/deps_linker.zig b/src/deps_linker.zig index b5e0dc63..95b7676d 100644 --- a/src/deps_linker.zig +++ b/src/deps_linker.zig @@ -7,6 +7,7 @@ const std = @import("std"); const config = @import("config.zig"); const cache = @import("cache.zig"); +const backend_registry = @import("backend_registry.zig"); const ProjectConfig = config.ProjectConfig; @@ -51,13 +52,12 @@ pub fn createDepsLinks( } { - const backend_name = @tagName(cfg.backend); - var subpath_buf: [128]u8 = undefined; - const subpath = std.fmt.bufPrint(&subpath_buf, "backends/{s}", .{backend_name}) catch unreachable; - const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, subpath); - const zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{backend_name}); - const link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{backend_name}); - try deps.append(allocator, .{ .zon_name = zon_name, .link_name = link_name, .abs_path = backend_path }); + const backend_info = try backend_registry.lookup(allocator, cfg.backendName()); + defer allocator.free(backend_info.subpath); + const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath); + // zon_name / link_name are moved into the DepEntry (freed by + // freeDepEntries), so we don't free them here. + try deps.append(allocator, .{ .zon_name = backend_info.zon_name, .link_name = backend_info.link_name, .abs_path = backend_path }); // Backend-owned transitive sub-package: the shared windowless-SDL // desktop gamepad source (`backends/sdl_gamepad/`, core#28). Both the diff --git a/src/gui_resolve.zig b/src/gui_resolve.zig index 5af1d5f4..e6457e21 100644 --- a/src/gui_resolve.zig +++ b/src/gui_resolve.zig @@ -47,7 +47,7 @@ pub fn resolveGuiPlugin(allocator: std.mem.Allocator, cfg: *config.ProjectConfig }; const bridge_def = getBridgeForBackend(bridges, cfg.backend) orelse { - std.debug.print("labelle: GUI plugin '{s}' requires a bridge for backend '{s}', but none is declared in gui.labelle.\n", .{ manifest.name, @tagName(cfg.backend) }); + std.debug.print("labelle: GUI plugin '{s}' requires a bridge for backend '{s}', but none is declared in gui.labelle.\n", .{ manifest.name, cfg.backendName() }); std.debug.print(" available bridges:", .{}); printAvailableBridges(bridges); std.debug.print("\n", .{}); @@ -58,12 +58,12 @@ pub fn resolveGuiPlugin(allocator: std.mem.Allocator, cfg: *config.ProjectConfig // Local bridge path (relative to plugin directory) bridge_dir = try std.fs.path.resolve(allocator, &.{ plugin_dir, rel_path }); } else { - std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has no .path (remote bridge resolution not yet supported)\n", .{ manifest.name, @tagName(cfg.backend) }); + std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has no .path (remote bridge resolution not yet supported)\n", .{ manifest.name, cfg.backendName() }); return error.GuiBridgeResolutionNotSupported; } if (bridge_def.adapter.len == 0) { - std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has empty .adapter name\n", .{ manifest.name, @tagName(cfg.backend) }); + std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has empty .adapter name\n", .{ manifest.name, cfg.backendName() }); return error.GuiBridgeMissingAdapter; } diff --git a/src/main.zig b/src/main.zig index 0632ff7e..d8633910 100644 --- a/src/main.zig +++ b/src/main.zig @@ -214,7 +214,7 @@ fn cmdGenerate(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args std.process.exit(1); }; - const target_name = try std.fmt.allocPrint(allocator, "{s}_{s}", .{ @tagName(cfg.backend), @tagName(cfg.platform) }); + const target_name = try std.fmt.allocPrint(allocator, "{s}_{s}", .{ cfg.backendName(), @tagName(cfg.platform) }); defer allocator.free(target_name); std.log.info("labelle-assembler: generated .labelle/{s}/", .{target_name}); diff --git a/src/root.zig b/src/root.zig index db13190a..f3f17690 100644 --- a/src/root.zig +++ b/src/root.zig @@ -5,6 +5,7 @@ const std = @import("std"); // ── Submodules ───────────────────────────────────────────────────────── const config = @import("config.zig"); const cache = @import("cache.zig"); +const backend_registry = @import("backend_registry.zig"); pub const scanner = @import("scanner.zig"); pub const scene_manifest = @import("scene_manifest.zig"); pub const asset_validator = @import("asset_validator.zig"); @@ -29,6 +30,7 @@ test { _ = @import("lazy_inference.zig"); _ = @import("cache.zig"); _ = @import("deps_linker.zig"); + _ = @import("backend_registry.zig"); _ = @import("app_icon.zig"); _ = @import("flow_catalog.zig"); _ = @import("codegen/idents.zig"); @@ -417,7 +419,7 @@ pub fn generate( const target_name = if (target_name_override) |name| try allocator.dupe(u8, name) else - try std.fmt.allocPrint(allocator, "{s}_{s}", .{ @tagName(cfg.backend), @tagName(cfg.platform) }); + try std.fmt.allocPrint(allocator, "{s}_{s}", .{ cfg.backendName(), @tagName(cfg.platform) }); defer allocator.free(target_name); const target_dir = try std.fs.path.join(allocator, &.{ output_dir, target_name }); defer allocator.free(target_dir); @@ -1000,8 +1002,6 @@ fn loadEngineTemplate(allocator: std.mem.Allocator, game_dir: []const u8, cfg: P /// Load the backend+platform lifecycle template from the CLI cache. fn loadBackendTemplate(allocator: std.mem.Allocator, game_dir: []const u8, cfg: ProjectConfig) ![]const u8 { - const backend_name = @tagName(cfg.backend); - // ── Manifest-driven main-loop template path (assembler#378) ───────── // When the manifest path is enabled (desktop + a backend that ships a // manifest), resolve the main-loop template from the backend manifest's @@ -1012,11 +1012,11 @@ fn loadBackendTemplate(allocator: std.mem.Allocator, game_dir: []const u8, cfg: if (manifest_splice.manifestPathEnabled(allocator, cfg, game_dir)) { const m = try manifest_splice.loadManifest(allocator, cfg, game_dir); defer manifest_splice.freeManifest(allocator, m); - // `m.dir_name` is a runtime-parsed manifest field — allocPrint, not a - // fixed buffer + `catch unreachable` that could panic on a long name. - const sub = try std.fmt.allocPrint(allocator, "backends/{s}", .{m.dir_name}); - defer allocator.free(sub); - const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, game_dir, sub); + // Package dir via the registry (centralized `backends/{name}`), keyed by + // the manifest's own `dir_name` — no enum, no inline path derivation. + const backend_info = try backend_registry.lookup(allocator, m.dir_name); + defer backend_registry.free(allocator, backend_info); + const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, game_dir, backend_info.subpath); defer allocator.free(backend_path); const tmpl_path = try std.fs.path.join(allocator, &.{ backend_path, manifest_splice.mainLoopTemplateRel(m) }); defer allocator.free(tmpl_path); @@ -1044,9 +1044,9 @@ fn loadBackendTemplate(allocator: std.mem.Allocator, game_dir: []const u8, cfg: defer allocator.free(tmpl_filename); // Resolve backend path from the assembler cache slot. - var backend_subpath_buf: [128]u8 = undefined; - const backend_subpath = std.fmt.bufPrint(&backend_subpath_buf, "backends/{s}", .{backend_name}) catch unreachable; - const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, game_dir, backend_subpath); + const backend_info = try backend_registry.lookup(allocator, cfg.backendName()); + defer backend_registry.free(allocator, backend_info); + const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, game_dir, backend_info.subpath); defer allocator.free(backend_path); const tmpl_path = try std.fs.path.join(allocator, &.{ backend_path, "templates", tmpl_filename }); From 9748d875b76447e7a301881e0e1622391bf549a8 Mon Sep 17 00:00:00 2001 From: apotema Date: Sun, 28 Jun 2026 14:23:39 -0300 Subject: [PATCH 2/3] splice: route backendPackageDir through backend_registry (close the Phase-5 seam locator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest-splice locator was the last @tagName(cfg.backend) in code (the registry PR's original base predated manifest_splice.zig, so the agent couldn't reach it). Now routed through backend_registry.lookup(cfg.backendName()) — the only residual enum coupling is config PARSING (.backend is still the enum). --- src/codegen/manifest_splice.zig | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/codegen/manifest_splice.zig b/src/codegen/manifest_splice.zig index d8ddea4c..c94ccdeb 100644 --- a/src/codegen/manifest_splice.zig +++ b/src/codegen/manifest_splice.zig @@ -43,6 +43,7 @@ const std = @import("std"); const tpl = @import("../template.zig"); const config = @import("../config.zig"); const cache = @import("../cache.zig"); +const backend_registry = @import("../backend_registry.zig"); const ProjectConfig = config.ProjectConfig; @@ -110,16 +111,18 @@ fn manifestExists(allocator: std.mem.Allocator, cfg: ProjectConfig, project_dir: /// slot) the same way `loadBackendTemplate` / `deps_linker` do. Caller owns the /// returned path. /// -/// SEAM (Phase 5): this is the one spot the splice still uses `@tagName` — to -/// LOCATE the package so it can read the manifest (chicken-and-egg: the dir -/// name lives in the manifest we haven't read yet). A production -/// name→package registry would map a backend *name string* (from -/// project.labelle) here, dropping the enum entirely. Everything downstream of -/// the read uses manifest data (`dir_name`/`dep_name`), not the tag. +/// Locate the backend package so the splice can read its manifest (chicken-and- +/// egg: the dir name lives in the manifest we haven't read yet, so we resolve by +/// the backend's *name*). Now routed through the `backend_registry` — keyed by +/// `cfg.backendName()`, a string, NOT `@tagName` directly. The only residual +/// enum coupling is config *parsing* (`.backend` is still the closed enum, so +/// `backendName()` only yields built-in names today); opening config to an +/// arbitrary name+package is the next step, after which a third-party backend +/// flows through this same registry lookup with no enum entry. fn backendPackageDir(allocator: std.mem.Allocator, cfg: ProjectConfig, project_dir: []const u8) ![]const u8 { - var subpath_buf: [128]u8 = undefined; - const subpath = std.fmt.bufPrint(&subpath_buf, "backends/{s}", .{@tagName(cfg.backend)}) catch unreachable; - return cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, subpath); + const backend_info = try backend_registry.lookup(allocator, cfg.backendName()); + defer backend_registry.free(allocator, backend_info); + return cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath); } /// Load + parse `backend.manifest.zon` from the backend package. From 44bdf7f8bc4584c6bf4e293a6e2f7994ac888a2b Mon Sep 17 00:00:00 2001 From: apotema Date: Sun, 28 Jun 2026 14:33:46 -0300 Subject: [PATCH 3/3] fix(registry): errdefer the partial allocations (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend_registry.lookup: allocate subpath/zon_name/link_name incrementally with errdefer so a mid-sequence OOM doesn't leak the already-allocated fields (the struct never returns → caller never frees). - deps_linker: tight nested scope with errdefer for the moved-into-DepEntry fields (zon_name/link_name/backend_path) — frees them on any error up to the append, but NOT after (ownership → deps), and scoped so the later gamepad appends can't re-trigger a double-free. Both Gemini + CodeRabbit. (gui_resolve debug.print are pre-existing — the PR only swapped the name arg; left for a separate file-wide log cleanup.) --- src/backend_registry.zig | 14 ++++++++------ src/deps_linker.zig | 23 +++++++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/backend_registry.zig b/src/backend_registry.zig index 76bb93f0..26eb04ef 100644 --- a/src/backend_registry.zig +++ b/src/backend_registry.zig @@ -51,12 +51,14 @@ pub const BackendInfo = struct { /// returned `BackendInfo`'s `subpath` / `zon_name` / `link_name` are /// allocator-owned; free them with `free`. `name` borrows the caller's slice. pub fn lookup(allocator: std.mem.Allocator, name: []const u8) !BackendInfo { - return .{ - .name = name, - .subpath = try std.fmt.allocPrint(allocator, "backends/{s}", .{name}), - .zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{name}), - .link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{name}), - }; + // Allocate incrementally with errdefer so a mid-sequence OOM doesn't leak the + // fields already allocated (the struct never returns, so callers never `free`). + const subpath = try std.fmt.allocPrint(allocator, "backends/{s}", .{name}); + errdefer allocator.free(subpath); + const zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{name}); + errdefer allocator.free(zon_name); + const link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{name}); + return .{ .name = name, .subpath = subpath, .zon_name = zon_name, .link_name = link_name }; } /// Free the allocator-owned fields of an `info` returned by `lookup`. diff --git a/src/deps_linker.zig b/src/deps_linker.zig index 95b7676d..06d2bc33 100644 --- a/src/deps_linker.zig +++ b/src/deps_linker.zig @@ -52,12 +52,23 @@ pub fn createDepsLinks( } { - const backend_info = try backend_registry.lookup(allocator, cfg.backendName()); - defer allocator.free(backend_info.subpath); - const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath); - // zon_name / link_name are moved into the DepEntry (freed by - // freeDepEntries), so we don't free them here. - try deps.append(allocator, .{ .zon_name = backend_info.zon_name, .link_name = backend_info.link_name, .abs_path = backend_path }); + // Tight scope: `zon_name`/`link_name`/`backend_path` are MOVED into the + // DepEntry on a successful append (then owned by `deps`, freed by + // freeDepEntries). The errdefers cover only the allocate→append window — + // on success this inner block exits normally so they don't fire; on any + // error up to and including the append they free the not-yet-moved + // allocations. Scoped tightly so the later gamepad appends (also failable) + // can't re-trigger them into a double-free. `subpath` is never moved, so + // it's a plain `defer`. + { + const backend_info = try backend_registry.lookup(allocator, cfg.backendName()); + defer allocator.free(backend_info.subpath); + errdefer allocator.free(backend_info.zon_name); + errdefer allocator.free(backend_info.link_name); + const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath); + errdefer allocator.free(backend_path); + try deps.append(allocator, .{ .zon_name = backend_info.zon_name, .link_name = backend_info.link_name, .abs_path = backend_path }); + } // Backend-owned transitive sub-package: the shared windowless-SDL // desktop gamepad source (`backends/sdl_gamepad/`, core#28). Both the