Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/provider-contract-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ From wire `1.6.0` the file may also carry `build_options`, the Zig build options

**Freshness.** The environment is built for each build from the inherited environment plus the contributions of the hooks that ran in that build; a watched rebuild starts over. A hook a replan removed leaves nothing behind. A watched rebuild that fails, at any stage, keeps the environment of the last successful build (the one being served, and the one the session's `after run` hooks get); only a rebuild that succeeds replaces it.

**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build <build_step>` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The core stages no runtime library beside a built exe: it only merges the contribution into the environment above. Staging one (a DLL next to a desktop exe, say) is a provider's job, in an `after build` hook, which sees the merged environment as the compile did; the `sdl2` provider's `stage` hook copies `SDL2.dll` this way (cli#471 S4).
**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the core generation itself (the `labelle-assembler generate` process, so a `before generate` contribution can steer what is generated; labelle-web's `LABELLE_WASM_THREADS`, labelle-web#24), the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build <build_step>` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The core stages no runtime library beside a built exe: it only merges the contribution into the environment above. Staging one (a DLL next to a desktop exe, say) is a provider's job, in an `after build` hook, which sees the merged environment as the compile did; the `sdl2` provider's `stage` hook copies `SDL2.dll` this way (cli#471 S4).

**No silent bypass.** A build path that cannot carry a provider's contribution or its optimize default refuses before any hook, generation or build, naming the hook or package. `--docker` builds in a container the contributions never reach, so a `build`, `run` or `bundle` (any command that reaches the container build) whose plan has a hook that may contribute (one in a contributing slot whose provider negotiates `1.3.0` or newer) is refused; `labelle generate --docker` is not, since it stops after generation and its host-side fingerprint pass gets the contributions, and neither is a build the target owner replaces, since its `replace build` hook gets them like every hook: `--docker doesn't carry provider environment contributions; build without --docker`. (`--docker` does pass the effective optimize mode as `-Doptimize`.) The legacy `labelle ios` subcommand, which ran its own `zig build` carrying neither and was refused for both, left the core with RFC cli#471 I5; `labelle build --platform=ios` carries both.

Expand Down
5 changes: 3 additions & 2 deletions docs/provider-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,9 @@ in a fresh per-invocation directory, where it may write
```

to contribute to the environment of everything that runs after it in the
same command: the generation-time fingerprint pass (`zig build
--list-steps`), the core compile, and every later hook and replacement. The
same command: the core generation itself (the `labelle-assembler generate`
process, so a `before generate` hook can steer what is generated), the
generation-time fingerprint pass (`zig build --list-steps`), the core compile, and every later hook and replacement. The
build of a provider's own tool never sees it. This is how a provider that
provisions a toolchain hands it to the build without the CLI knowing the
toolchain: a `before generate` hook is early enough for the fingerprint
Expand Down
74 changes: 72 additions & 2 deletions src/cli/assembler_proc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const config = @import("config.zig");
const supervise = @import("supervise.zig");
const assembler = @import("assembler.zig");
const progress = @import("progress.zig");
const provider_env = @import("provider_env.zig");
const runner = @import("runner.zig");

/// Global floor: every subcommand the CLI delegates needs at least this
/// protocol (`install`/`clean`/`upgrade` arrived at protocol 2, `init` at
Expand Down Expand Up @@ -104,7 +106,18 @@ pub const Assembler = struct {
subcommand: []const u8,
args: []const []const u8,
) !void {
return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure);
return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure, null);
}

/// `run` with an explicit child environment (null inherits ours).
pub fn runWithEnv(
self: Assembler,
allocator: std.mem.Allocator,
subcommand: []const u8,
args: []const []const u8,
env: ?*const std.process.Environ.Map,
) !void {
return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure, env);
}
};

Expand Down Expand Up @@ -207,20 +220,44 @@ pub fn runSubcommand(
/// user-facing `--scene` flag; the assembler always compiles a stable
/// artifact with `initial_prefab` taken straight from `project.labelle`.
///
/// `contributed` is the environment this build's provider hooks have
/// contributed so far (contract §2 `env_file`): the `before generate` hooks
/// run first, so a provider can steer generation itself, not only the
/// compile (a provider-owned generation switch, say).
/// Null or empty: the assembler inherits the CLI's environment, as before.
///
/// On a non-zero exit code, returns `error.AssemblerFailed`; the binary's
/// inherited stderr already explains the failure.
pub fn generate(
asm_bin: Assembler,
allocator: std.mem.Allocator,
project_dir: []const u8,
target: []const u8,
contributed: ?*const provider_env.Accumulator,
) !void {
var args: std.ArrayList([]const u8) = .empty;
defer args.deinit(allocator);

try buildGenerateArgs(allocator, &args, project_dir, target);

try asm_bin.run(allocator, "generate", args.items);
var base = try runner.buildEnvironWithExtra(allocator, &.{});
defer base.deinit();
var env_storage = try generateEnv(allocator, &base, contributed);
defer if (env_storage) |*m| m.deinit();
try asm_bin.runWithEnv(allocator, "generate", args.items, if (env_storage) |*m| m else null);
}

/// The environment `generate` hands the assembler: `base` (our own
/// environment) with the hooks' contribution merged on top, or null (inherit
/// unchanged) when nothing was contributed. Caller deinits a returned map.
fn generateEnv(
allocator: std.mem.Allocator,
base: *const std.process.Environ.Map,
contributed: ?*const provider_env.Accumulator,
) !?std.process.Environ.Map {
const env = contributed orelse return null;
if (env.isEmpty()) return null;
return try env.compose(allocator, base);
}

/// Build the argv slice for the assembler's `generate` subcommand.
Expand Down Expand Up @@ -256,6 +293,37 @@ test {
/// argv this module sends to `labelle-assembler generate` must NOT
/// contain `--scene`. The override travels at runtime via the
/// `LABELLE_SCENE` env var the CLI injects when it spawns the game.
/// The `before generate` hooks' environment reaches the assembler process
/// (contract §2): a provider can steer generation, not only the compile.
pub const GenerateEnvSpec = struct {
test "a contributed variable reaches the assembler's environment" {
const a = std.testing.allocator;
var acc: provider_env.Accumulator = .{};
defer acc.deinit();
var diag: provider_env.Diagnostic = .{};
var arena = std.heap.ArenaAllocator.init(a);
defer arena.deinit();
try acc.add(a, arena.allocator(), "p/toolchain", .{ .set = &.{.{ .name = "PROBE_GENERATE_SWITCH", .value = "1" }} }, &diag);
var base = std.process.Environ.Map.init(a);
defer base.deinit();
try base.put("KEEP_ME", "yes");
var env = (try generateEnv(a, &base, &acc)).?;
defer env.deinit();
try std.testing.expectEqualStrings("1", env.get("PROBE_GENERATE_SWITCH").?);
try std.testing.expectEqualStrings("yes", env.get("KEEP_ME").?);
}

test "no contribution: the assembler inherits our environment unchanged" {
const a = std.testing.allocator;
var base = std.process.Environ.Map.init(a);
defer base.deinit();
try std.testing.expect((try generateEnv(a, &base, null)) == null);
var empty: provider_env.Accumulator = .{};
defer empty.deinit();
try std.testing.expect((try generateEnv(a, &base, &empty)) == null);
}
};

pub const BuildGenerateArgsSpec = struct {
pub const no_scene_forwarding = struct {
test "argv to assembler never contains --scene" {
Expand Down Expand Up @@ -352,6 +420,7 @@ fn spawnAndWait(
subcommand: []const u8,
args: []const []const u8,
fatal_on_failure: bool,
env: ?*const std.process.Environ.Map,
) !void {
const io = config.globalIo();

Expand All @@ -366,6 +435,7 @@ fn spawnAndWait(
.stdin = .inherit,
.stdout = .inherit,
.stderr = .inherit,
.environ_map = env,
}) catch |err| {
std.debug.print("labelle: could not launch assembler '{s}': {any}\n", .{ exe_path, err });
return error.AssemblerFailed;
Expand Down
4 changes: 3 additions & 1 deletion src/cli/pipeline/generate.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ pub fn run(cx: *const Context, generate_out: []const u8) !?u8 {

// The assembler receives the resolved target NAME; the gate after the
// install (`describe`'s `supported`) guarantees it can take it.
try assembler_proc.generate(cx.asm_bin, allocator, project_dir, target.name);
// The `before generate` hooks' environment reaches the assembler too
// (contract §2): a provider can steer generation, not only the compile.
try assembler_proc.generate(cx.asm_bin, allocator, project_dir, target.name, &hook_site.env);

// (`target_name`/`target_dir` — .labelle/<backend>_<target>/, named by `describe` — are
// computed up front, before the progress reporter init; see cli#284.)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/pipeline/rebuild.zig
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ pub const RebuildCtx = struct {
var asm_bin = self.asm_bin;
// A failed regeneration fails this rebuild, never the session.
asm_bin.fatal_on_failure = false;
assembler_proc.generate(asm_bin, a, self.project_dir, self.platform_tag) catch |err| {
assembler_proc.generate(asm_bin, a, self.project_dir, self.platform_tag, &self.hooks.env) catch |err| {
if (self.canceled()) return error.Canceled;
std.debug.print("labelle: rebuild generate failed ({s})\n", .{@errorName(err)});
return error.GenerateFailed;
Expand Down
3 changes: 2 additions & 1 deletion src/cli/provider_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ pub const Site = struct {
/// The environment the hooks of the CURRENT build contributed through
/// their `env_file` (contract §2, wire `1.3.0`+), merged in hook
/// execution order. Every later hook and replacement runs with it, and
/// the pipeline applies it to the fingerprint pass and the compile. A
/// the pipeline applies it to the assembler's generate, the fingerprint
/// pass and the compile. A
/// rebuild `reset`s it first, so a hook that no longer runs leaves
/// nothing behind. The pipeline deinits it.
env: provider_env.Accumulator = .{},
Expand Down
Loading